diff --git a/internal/tui/bootstrap/.gitkeep b/internal/tui/bootstrap/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/tui/bootstrap/builder.go b/internal/tui/bootstrap/builder.go new file mode 100644 index 00000000..739b6970 --- /dev/null +++ b/internal/tui/bootstrap/builder.go @@ -0,0 +1,90 @@ +package bootstrap + +import ( + "context" + "fmt" + + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" +) + +// ProviderService 定义 TUI 需要注入的 provider 交互能力。 +type ProviderService interface { + ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) + SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) + ListModels(ctx context.Context) ([]config.ModelDescriptor, error) + ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) + SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) +} + +// Options 定义 bootstrap 装配输入。 +type Options struct { + Config *config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderService ProviderService + Mode Mode + Factory ServiceFactory +} + +// Container 表示完成装配后供 TUI Core 使用的依赖集合。 +type Container struct { + Config config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderService ProviderService + Mode Mode +} + +// Build 执行 TUI bootstrap 装配,并返回可注入到 App/Core 的容器。 +func Build(options Options) (Container, error) { + if options.ConfigManager == nil { + return Container{}, fmt.Errorf("tui bootstrap: config manager is nil") + } + if options.Runtime == nil { + return Container{}, fmt.Errorf("tui bootstrap: runtime is nil") + } + if options.ProviderService == nil { + return Container{}, fmt.Errorf("tui bootstrap: provider service is nil") + } + + mode := NormalizeMode(options.Mode) + cfg := resolveConfigSnapshot(options.Config, options.ConfigManager) + + factory := options.Factory + if factory == nil { + factory = passthroughFactory{} + } + + runtimeSvc, err := factory.BuildRuntime(mode, options.Runtime) + if err != nil { + return Container{}, fmt.Errorf("tui bootstrap: build runtime: %w", err) + } + if runtimeSvc == nil { + return Container{}, fmt.Errorf("tui bootstrap: runtime factory returned nil") + } + + providerSvc, err := factory.BuildProvider(mode, options.ProviderService) + if err != nil { + return Container{}, fmt.Errorf("tui bootstrap: build provider service: %w", err) + } + if providerSvc == nil { + return Container{}, fmt.Errorf("tui bootstrap: provider factory returned nil") + } + + return Container{ + Config: cfg, + ConfigManager: options.ConfigManager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Mode: mode, + }, nil +} + +// resolveConfigSnapshot 返回用于本次 TUI 初始化的配置快照。 +func resolveConfigSnapshot(cfg *config.Config, manager *config.Manager) config.Config { + if cfg == nil { + return manager.Get() + } + return cfg.Clone() +} diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go new file mode 100644 index 00000000..71a9cf06 --- /dev/null +++ b/internal/tui/bootstrap/builder_test.go @@ -0,0 +1,166 @@ +package bootstrap + +import ( + "context" + "testing" + + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" +) + +type testRuntime struct{} + +func (r *testRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r *testRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r *testRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + return nil +} + +func (r *testRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r *testRuntime) CancelActiveRun() bool { + return false +} + +func (r *testRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (r *testRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (r *testRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +type testProviderService struct{} + +func (s *testProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, nil +} + +func (s *testProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func (s *testProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s *testProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s *testProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func TestBuild(t *testing.T) { + t.Run("success", func(t *testing.T) { + manager := &config.Manager{} + runtime := &testRuntime{} + providerSvc := &testProviderService{} + + container, err := Build(Options{ + ConfigManager: manager, + Runtime: runtime, + ProviderService: providerSvc, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if container.ConfigManager != manager { + t.Error("expected ConfigManager to be set") + } + }) + + t.Run("nil config manager", func(t *testing.T) { + _, err := Build(Options{ + ConfigManager: nil, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + }) + if err == nil { + t.Fatal("expected error for nil config manager") + } + }) + + t.Run("nil runtime", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: nil, + ProviderService: &testProviderService{}, + }) + if err == nil { + t.Fatal("expected error for nil runtime") + } + }) + + t.Run("nil provider service", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: nil, + }) + if err == nil { + t.Fatal("expected error for nil provider service") + } + }) +} + +func TestResolveConfigSnapshot(t *testing.T) { + t.Run("nil config returns manager get", func(t *testing.T) { + manager := &config.Manager{} + cfg := resolveConfigSnapshot(nil, manager) + if cfg.Workdir == "" && cfg.Shell == "" { + t.Log("config returned from manager") + } + }) + + t.Run("config provided returns clone", func(t *testing.T) { + manager := &config.Manager{} + inputCfg := &config.Config{ + Workdir: "/test", + } + cfg := resolveConfigSnapshot(inputCfg, manager) + if cfg.Workdir != "/test" { + t.Errorf("expected Workdir /test, got %s", cfg.Workdir) + } + }) +} + +func TestNormalizeMode(t *testing.T) { + tests := []struct { + name string + input Mode + want Mode + }{ + {"empty becomes live", "", ModeLive}, + {"live stays live", ModeLive, ModeLive}, + {"offline stays offline", ModeOffline, ModeOffline}, + {"mock stays mock", ModeMock, ModeMock}, + {"unknown becomes live", Mode("unknown"), ModeLive}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeMode(tt.input); got != tt.want { + t.Errorf("NormalizeMode(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/tui/bootstrap/factory.go b/internal/tui/bootstrap/factory.go new file mode 100644 index 00000000..9077cdc5 --- /dev/null +++ b/internal/tui/bootstrap/factory.go @@ -0,0 +1,25 @@ +package bootstrap + +import ( + agentruntime "neo-code/internal/runtime" +) + +// ServiceFactory 定义 runtime/provider 的可切换装配策略。 +type ServiceFactory interface { + // BuildRuntime 根据 mode 返回实际注入到 TUI 的 runtime 实现。 + BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) + // BuildProvider 根据 mode 返回实际注入到 TUI 的 provider service 实现。 + BuildProvider(mode Mode, current ProviderService) (ProviderService, error) +} + +type passthroughFactory struct{} + +// BuildRuntime 默认直接透传已有 runtime,不做替换。 +func (passthroughFactory) BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) { + return current, nil +} + +// BuildProvider 默认直接透传已有 provider service,不做替换。 +func (passthroughFactory) BuildProvider(mode Mode, current ProviderService) (ProviderService, error) { + return current, nil +} diff --git a/internal/tui/bootstrap/mode.go b/internal/tui/bootstrap/mode.go new file mode 100644 index 00000000..8abf93b1 --- /dev/null +++ b/internal/tui/bootstrap/mode.go @@ -0,0 +1,27 @@ +package bootstrap + +import "strings" + +// Mode 定义 TUI bootstrap 的装配模式。 +type Mode string + +const ( + // ModeLive 表示使用真实依赖进行正常装配。 + ModeLive Mode = "live" + // ModeOffline 表示使用离线装配策略(可由工厂映射为本地实现)。 + ModeOffline Mode = "offline" + // ModeMock 表示使用 mock 装配策略(通常用于测试)。 + ModeMock Mode = "mock" +) + +// NormalizeMode 归一化 mode 输入,未知值默认回退到 live。 +func NormalizeMode(mode Mode) Mode { + switch Mode(strings.ToLower(strings.TrimSpace(string(mode)))) { + case ModeOffline: + return ModeOffline + case ModeMock: + return ModeMock + default: + return ModeLive + } +} diff --git a/internal/tui/command_menu_test.go b/internal/tui/command_menu_test.go deleted file mode 100644 index dfdfcfe3..00000000 --- a/internal/tui/command_menu_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" -) - -func TestCommandMenuItemAndDelegateHelpers(t *testing.T) { - item := commandMenuItem{ - title: "/status", - description: "show status", - filter: " custom FILTER ", - } - if item.Title() != "/status" || item.Description() != "show status" { - t.Fatalf("unexpected title/description: %+v", item) - } - if got := item.FilterValue(); got != "custom filter" { - t.Fatalf("expected trimmed lowercase filter, got %q", got) - } - - item.filter = "" - if got := item.FilterValue(); got != "/status show status" { - t.Fatalf("expected fallback filter text, got %q", got) - } - - delegate := commandMenuDelegate{styles: newStyles()} - if delegate.Height() != 1 || delegate.Spacing() != 0 { - t.Fatalf("unexpected delegate size: height=%d spacing=%d", delegate.Height(), delegate.Spacing()) - } - if cmd := delegate.Update(nil, nil); cmd != nil { - t.Fatalf("expected nil update cmd, got %v", cmd) - } -} - -func TestCommandMenuBehaviorPaths(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.state.CurrentWorkdir = t.TempDir() - app.fileCandidates = []string{"internal/tui/update.go", "internal/tui/view.go"} - app.input.SetValue("inspect @") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - if !app.commandMenuHasSuggestions() || app.commandMenuMeta.Title != fileMenuTitle { - t.Fatalf("expected file menu suggestions, meta=%+v items=%d", app.commandMenuMeta, len(app.commandMenu.Items())) - } - if !app.applySelectedCommandSuggestion() { - t.Fatalf("expected browse suggestion to open file browser") - } - if app.state.ActivePicker != pickerFile { - t.Fatalf("expected pickerFile after browse suggestion, got %v", app.state.ActivePicker) - } - - app.closePicker() - app.input.SetValue("/") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - if len(app.commandMenu.Items()) == 0 { - t.Fatalf("expected slash command menu items") - } - if len(app.commandMenu.Items()) > 1 { - selectedTitle := app.commandMenu.Items()[1].(commandMenuItem).title - app.commandMenu.Select(1) - app.refreshCommandMenu() - currentTitle := app.commandMenu.SelectedItem().(commandMenuItem).title - if currentTitle != selectedTitle { - t.Fatalf("expected selection retained, got %q want %q", currentTitle, selectedTitle) - } - } - - if _, handled := app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyDown}); !handled { - t.Fatalf("expected key down to be handled") - } - if _, handled := app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}); handled { - t.Fatalf("expected rune key not to be handled by command menu") - } - - app.state.ActivePicker = pickerModel - app.refreshCommandMenu() - if app.commandMenuHasSuggestions() || strings.TrimSpace(app.commandMenuMeta.Title) != "" { - t.Fatalf("expected menu cleared while picker active") - } - app.state.ActivePicker = pickerNone - - app.commandMenu.SetItems([]list.Item{selectionItem{id: "x", name: "not command item"}}) - app.commandMenu.Select(0) - if app.applySelectedCommandSuggestion() { - t.Fatalf("expected false when selected item type is invalid") - } - - app.input.SetValue("abc") - app.state.InputText = app.input.Value() - app.commandMenu.SetItems([]list.Item{commandMenuItem{ - replacement: "ignored", - useReplaceRange: true, - replaceStart: -1, - replaceEnd: 1, - }}) - app.commandMenu.Select(0) - if app.applySelectedCommandSuggestion() { - t.Fatalf("expected false for invalid replace range") - } - - app.commandMenu.SetItems([]list.Item{commandMenuItem{replacement: "/status"}}) - app.commandMenu.Select(0) - if !app.applySelectedCommandSuggestion() || app.state.InputText != "/status" { - t.Fatalf("expected direct replacement, got %q", app.state.InputText) - } - app.commandMenu.SetItems([]list.Item{commandMenuItem{replacement: "/status"}}) - app.commandMenu.Select(0) - if app.applySelectedCommandSuggestion() { - t.Fatalf("expected no-op replacement to return false") - } -} - -func TestBuildCommandMenuItemsVariants(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.state.CurrentWorkdir = t.TempDir() - - items, meta := app.buildCommandMenuItems("&", 80) - if len(items) != 1 || meta.Title != shellMenuTitle || !items[0].useReplaceRange { - t.Fatalf("expected bare workspace command helper, got meta=%+v items=%+v", meta, items) - } - - items, meta = app.buildCommandMenuItems("& git status", 80) - if len(items) != 1 || meta.Title != shellMenuTitle || items[0].useReplaceRange { - t.Fatalf("expected concrete workspace command helper, got meta=%+v items=%+v", meta, items) - } - - items, meta = app.buildCommandMenuItems("not-a-command", 80) - if len(items) != 0 || strings.TrimSpace(meta.Title) != "" { - t.Fatalf("expected no suggestions for plain input, got meta=%+v items=%+v", meta, items) - } - - app.fileCandidates = []string{"internal/tui/update.go"} - fileItems := app.fileMenuSuggestions("inspect @") - if len(fileItems) == 0 || !fileItems[0].openFileBrowser { - t.Fatalf("expected browse item for empty file query, got %+v", fileItems) - } - - app.fileCandidates = nil - if suggestions := app.fileMenuSuggestions("inspect @missing"); len(suggestions) != 0 { - t.Fatalf("expected empty suggestions when query misses all candidates, got %+v", suggestions) - } - - app.state.CurrentWorkdir = "" - app.openFileBrowser() - if app.state.ActivePicker != pickerNone { - t.Fatalf("expected openFileBrowser to no-op when workdir is empty") - } -} diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go deleted file mode 100644 index bf889406..00000000 --- a/internal/tui/commands_test.go +++ /dev/null @@ -1,344 +0,0 @@ -package tui - -import ( - "context" - "encoding/binary" - "strings" - "testing" - "unicode/utf16" - - "neo-code/internal/config" -) - -func TestExecuteLocalCommand(t *testing.T) { - tests := []struct { - name string - command string - expectErr string - assert func(t *testing.T, manager *config.Manager, notice string) - }{ - { - name: "help lists supported slash commands", - command: "/help", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - for _, want := range []string{ - slashUsageHelp, - slashUsageClear, - slashUsageStatus, - slashUsageWorkdir, - slashUsageProvider, - slashUsageModel, - slashUsageExit, - } { - if !strings.Contains(notice, want) { - t.Fatalf("expected help output to contain %q, got %q", want, notice) - } - } - for _, unwanted := range []string{"/run", "/git", "/file", "/plan", "/undo", "/setting", "/set"} { - if strings.Contains(notice, unwanted) { - t.Fatalf("expected help output not to contain %q, got %q", unwanted, notice) - } - } - }, - }, - { - name: "status includes current tui snapshot", - command: "/status", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - for _, want := range []string{ - "Status:", - "Session: Draft", - "Running: no", - "Provider: " + manager.Get().SelectedProvider, - "Model: " + manager.Get().CurrentModel, - "Focus: " + focusLabelComposer, - "Picker: none", - "Messages: 0", - } { - if !strings.Contains(notice, want) { - t.Fatalf("expected status output to contain %q, got %q", want, notice) - } - } - }, - }, - { - name: "provider switches current provider when arg is provided", - command: "/provider gemini", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - cfg := manager.Get() - if cfg.SelectedProvider != config.GeminiName { - t.Fatalf("expected selected provider gemini, got %q", cfg.SelectedProvider) - } - if !strings.Contains(notice, "Current provider switched") { - t.Fatalf("expected provider switch notice, got %q", notice) - } - }, - }, - { - name: "provider without arg returns usage", - command: "/provider", - expectErr: "usage:", - }, - { - name: "unknown command is rejected", - command: "/unknown", - expectErr: `unknown command "/unknown"`, - }, - { - name: "empty command is rejected", - command: " ", - expectErr: "empty command", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - manager := newTestConfigManager(t) - providerSvc := newTestProviderService(t, manager) - notice, err := executeLocalCommand(context.Background(), manager, providerSvc, defaultTestStatusSnapshot(manager), tt.command) - if tt.expectErr != "" { - if err == nil || !strings.Contains(err.Error(), tt.expectErr) { - t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if tt.assert != nil { - tt.assert(t, manager, notice) - } - }) - } -} - -func TestMatchingSlashCommands(t *testing.T) { - t.Parallel() - - app := App{} - tests := []struct { - name string - input string - expectCount int - expectUsage string - }{ - { - name: "non slash input returns no suggestions", - input: "hello", - expectCount: 0, - }, - { - name: "bare slash returns supported commands only", - input: "/", - expectCount: len(builtinSlashCommands), - expectUsage: slashUsageHelp, - }, - { - name: "prefix narrows suggestions", - input: "/mo", - expectCount: 1, - expectUsage: slashUsageModel, - }, - { - name: "complete slash command hides suggestions", - input: "/status", - expectCount: 0, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := app.matchingSlashCommands(tt.input) - if len(got) != tt.expectCount { - t.Fatalf("expected %d suggestions, got %d", tt.expectCount, len(got)) - } - if tt.expectUsage != "" && (len(got) == 0 || got[0].Command.Usage != tt.expectUsage && !containsUsage(got, tt.expectUsage)) { - t.Fatalf("expected suggestions to contain %q, got %+v", tt.expectUsage, got) - } - }) - } -} - -func containsUsage(suggestions []commandSuggestion, usage string) bool { - for _, suggestion := range suggestions { - if suggestion.Command.Usage == usage { - return true - } - } - return false -} - -func defaultTestStatusSnapshot(manager *config.Manager) statusSnapshot { - cfg := manager.Get() - return statusSnapshot{ - ActiveSessionTitle: draftSessionTitle, - CurrentProvider: cfg.SelectedProvider, - CurrentModel: cfg.CurrentModel, - CurrentWorkdir: cfg.Workdir, - FocusLabel: focusLabelComposer, - PickerLabel: "none", - } -} - -func TestCommandHelperFunctions(t *testing.T) { - t.Run("workspace slash parser supports aliases", func(t *testing.T) { - if !isWorkspaceSlashCommand("/cwd ./tmp") { - t.Fatalf("expected /cwd to be recognized") - } - if isWorkspaceSlashCommand("/status") { - t.Fatalf("expected non-workspace slash command to be ignored") - } - args, err := parseWorkspaceSlashCommand("/cwd") - if err != nil || args != "" { - t.Fatalf("expected empty args for /cwd, got %q / %v", args, err) - } - args, err = parseWorkspaceSlashCommand("/cwd ./tmp") - if err != nil || args != "./tmp" { - t.Fatalf("expected ./tmp, got %q / %v", args, err) - } - if _, err := parseWorkspaceSlashCommand("/workspace ./tmp"); err == nil { - t.Fatalf("expected /workspace to be rejected") - } - if _, err := parseWorkspaceSlashCommand("/status"); err == nil { - t.Fatalf("expected unknown slash command to return error") - } - }) - - t.Run("splitFirstWord handles empty and remainder", func(t *testing.T) { - if first, rest := splitFirstWord(" "); first != "" || rest != "" { - t.Fatalf("expected empty split, got %q / %q", first, rest) - } - if first, rest := splitFirstWord("alpha beta gamma"); first != "alpha" || rest != "beta gamma" { - t.Fatalf("unexpected split result %q / %q", first, rest) - } - }) - - t.Run("powershell shell args force utf8 output", func(t *testing.T) { - got := shellArgs("powershell", "git status") - if len(got) != 4 || got[0] != "powershell" { - t.Fatalf("unexpected powershell args %+v", got) - } - if !strings.Contains(got[3], "65001") || !strings.Contains(got[3], "git status") { - t.Fatalf("expected utf8 powershell wrapper, got %q", got[3]) - } - }) - - t.Run("sanitize workspace output strips ansi and invalid bytes", func(t *testing.T) { - raw := "\x1b[31mfatal\x1b[0m:\xff bad\r\nnext\x00line" - got := sanitizeWorkspaceOutput([]byte(raw)) - if strings.Contains(got, "\x1b") || strings.Contains(got, "\x00") { - t.Fatalf("expected control chars to be removed, got %q", got) - } - for _, want := range []string{"fatal", "bad", "nextline"} { - if !strings.Contains(strings.ReplaceAll(got, "\n", ""), want) { - t.Fatalf("expected sanitized output to contain %q, got %q", want, got) - } - } - }) - - t.Run("sanitize workspace output decodes utf16le launcher errors", func(t *testing.T) { - text := "This app needs WSL installed.\r\nRun wsl.exe --list --online" - encoded := utf16.Encode([]rune(text)) - raw := make([]byte, 0, len(encoded)*2) - for _, word := range encoded { - buf := make([]byte, 2) - binary.LittleEndian.PutUint16(buf, word) - raw = append(raw, buf...) - } - - got := sanitizeWorkspaceOutput(raw) - for _, want := range []string{"This app needs WSL installed.", "Run wsl.exe --list --online"} { - if !strings.Contains(got, want) { - t.Fatalf("expected decoded output to contain %q, got %q", want, got) - } - } - }) - - t.Run("decode workspace output prefers utf16 when chinese prefix has no zero bytes", func(t *testing.T) { - text := "Access denied.\r\nError code: Bash/Service/CreateInstance/E_ACCESSDENIED" - encoded := utf16.Encode([]rune(text)) - raw := make([]byte, 0, len(encoded)*2) - for _, word := range encoded { - buf := make([]byte, 2) - binary.LittleEndian.PutUint16(buf, word) - raw = append(raw, buf...) - } - - got := decodeWorkspaceOutput(raw) - for _, want := range []string{"Access denied.", "Error code", "E_ACCESSDENIED"} { - if !strings.Contains(got, want) { - t.Fatalf("expected decoded utf16 output to contain %q, got %q", want, got) - } - } - }) -} - -func TestLocalCommandWrappers(t *testing.T) { - manager := newTestConfigManager(t) - providerSvc := newTestProviderService(t, manager) - - msg := runLocalCommand(manager, providerSvc, defaultTestStatusSnapshot(manager), "/help")() - result, ok := msg.(localCommandResultMsg) - if !ok || result.err != nil || !strings.Contains(result.notice, "Available slash commands") { - t.Fatalf("expected help command result, got %+v", msg) - } - - msg = runProviderSelection(providerSvc, "missing-provider")() - result, ok = msg.(localCommandResultMsg) - if !ok || result.err == nil { - t.Fatalf("expected provider selection error, got %+v", msg) - } -} - -func TestExecuteStatusCommandSnapshot(t *testing.T) { - notice := executeStatusCommand(statusSnapshot{ - ActiveSessionID: "session-123", - ActiveSessionTitle: "Implement slash UX", - IsAgentRunning: true, - CurrentProvider: "openai", - CurrentModel: "gpt-5.4", - CurrentWorkdir: `D:\repo`, - CurrentTool: "bash", - ExecutionError: "tool failed", - FocusLabel: focusLabelTranscript, - PickerLabel: "model", - MessageCount: 7, - }) - for _, want := range []string{ - "Session: Implement slash UX", - "Session ID: session-123", - "Running: yes", - "Provider: openai", - "Model: gpt-5.4", - "Focus: Transcript", - "Picker: model", - "Current Tool: bash", - "Messages: 7", - "Error: tool failed", - } { - if !strings.Contains(notice, want) { - t.Fatalf("expected status output to contain %q, got %q", want, notice) - } - } -} - -func TestExecuteStatusCommandTreatsCompactingAsRunning(t *testing.T) { - notice := executeStatusCommand(statusSnapshot{ - ActiveSessionTitle: draftSessionTitle, - IsCompacting: true, - CurrentProvider: "openai", - CurrentModel: "gpt-5.4", - CurrentWorkdir: `D:\repo`, - FocusLabel: focusLabelComposer, - PickerLabel: "none", - }) - if !strings.Contains(notice, "Running: yes") { - t.Fatalf("expected compacting state to be reported as running, got %q", notice) - } -} diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go deleted file mode 100644 index 9cadcee0..00000000 --- a/internal/tui/copy_code_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package tui - -import ( - "errors" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - - providertypes "neo-code/internal/provider/types" -) - -func TestExtractFencedCodeBlocks(t *testing.T) { - content := "before\n```go\nfmt.Println(1)\n```\nmid\n```bash\necho hi\n```\nafter" - blocks := extractFencedCodeBlocks(content) - if len(blocks) != 2 { - t.Fatalf("expected 2 code blocks, got %d", len(blocks)) - } - if blocks[0] != "fmt.Println(1)" { - t.Fatalf("expected first code block to strip language tag, got %q", blocks[0]) - } - if blocks[1] != "echo hi" { - t.Fatalf("expected second code block to strip language tag, got %q", blocks[1]) - } -} - -func TestExtractFencedCodeBlocksWithoutLanguageKeepsFirstLine(t *testing.T) { - content := "before\n```\nSELECT\nFROM users;\n```\nafter" - blocks := extractFencedCodeBlocks(content) - if len(blocks) != 1 { - t.Fatalf("expected 1 code block, got %d", len(blocks)) - } - if !strings.Contains(blocks[0], "SELECT") || !strings.Contains(blocks[0], "FROM users;") { - t.Fatalf("expected full code block content, got %q", blocks[0]) - } -} - -func TestExtractFencedCodeBlocksFromIndentedMarkdown(t *testing.T) { - content := "intro\n\n package main\n import \"fmt\"\n\nending" - blocks := extractFencedCodeBlocks(content) - if len(blocks) != 1 { - t.Fatalf("expected 1 code block from indented markdown, got %d", len(blocks)) - } - if !strings.Contains(blocks[0], "package main") || !strings.Contains(blocks[0], "import \"fmt\"") { - t.Fatalf("expected extracted indented code block, got %q", blocks[0]) - } -} - -func TestParseCopyCodeButtonID(t *testing.T) { - id, startCol, endCol, ok := parseCopyCodeButton("[Copy code #12]") - if !ok || id != 12 { - t.Fatalf("expected id=12 parse success, got id=%d ok=%v", id, ok) - } - if startCol != 0 || endCol <= startCol { - t.Fatalf("expected valid button range, got start=%d end=%d", startCol, endCol) - } - - if _, _, _, ok := parseCopyCodeButton("no button"); ok { - t.Fatalf("expected parse failure for non-button line") - } -} - -func TestRenderMessageBlockWithCopyAddsButtons(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - rendered, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, "```go\nfmt.Println(1)\n```"), 80, 1) - if !strings.Contains(rendered, "[Copy code #1]") { - t.Fatalf("expected copy button in rendered message, got %q", rendered) - } - plain := stripANSI(rendered) - if strings.Index(plain, "[Copy code #1]") > strings.Index(plain, "fmt.Println(1)") { - t.Fatalf("expected copy button to render above code block, got %q", plain) - } - if len(bindings) != 1 || bindings[0].ID != 1 || bindings[0].Code != "fmt.Println(1)" { - t.Fatalf("unexpected bindings: %+v", bindings) - } -} - -func TestRenderMessageBlockWithCopyPreservesCodeIndentation(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - content := "```go\nfunc main() {\n\tif true {\n\t\tprintln(\"ok\")\n\t}\n}\n```" - _, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, content), 80, 1) - if len(bindings) != 1 { - t.Fatalf("expected one copy binding, got %+v", bindings) - } - if !strings.Contains(bindings[0].Code, "\tif true {") || !strings.Contains(bindings[0].Code, "\t\tprintln(\"ok\")") { - t.Fatalf("expected indentation preserved in copied code, got %q", bindings[0].Code) - } -} - -func TestRenderMessageBlockWithCopyAddsButtonsForIndentedCode(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - content := "璇存槑锛歕n\n package main\n import \"fmt\"" - rendered, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, content), 80, 1) - if !strings.Contains(stripANSI(rendered), "[Copy code #1]") { - t.Fatalf("expected copy button for indented markdown code, got %q", rendered) - } - if len(bindings) != 1 || !strings.Contains(bindings[0].Code, "package main") { - t.Fatalf("unexpected bindings for indented markdown code: %+v", bindings) - } -} - -func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - originalClipboardWrite := clipboardWriteAll - t.Cleanup(func() { clipboardWriteAll = originalClipboardWrite }) - - copied := "" - clipboardWriteAll = func(text string) error { - copied = text - return nil - } - - app.width = 128 - app.height = 40 - app.activeMessages = []providertypes.Message{ - {Role: roleAssistant, Content: "```go\nfmt.Println(1)\n```"}, - } - app.applyComponentLayout(true) - app.rebuildTranscript() - - x, y, _, _ := app.transcriptBounds() - lines := strings.Split(stripANSI(app.transcript.View()), "\n") - targetY := -1 - targetX := -1 - for i, line := range lines { - col := strings.Index(line, "[Copy code #1]") - if col >= 0 { - targetY = i - targetX = col - break - } - } - if targetY < 0 || targetX < 0 { - t.Fatalf("expected visible copy button in transcript view, got %q", app.transcript.View()) - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Button: tea.MouseButtonLeft, - }); !handled { - t.Fatalf("expected mouse press on copy button to be handled") - } - if copied != "" { - t.Fatalf("expected press phase not to copy yet, got %q", copied) - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Action: tea.MouseActionRelease, - Type: tea.MouseRelease, - }); !handled { - t.Fatalf("expected mouse release on copy button to be handled") - } - - if copied != "fmt.Println(1)" { - t.Fatalf("expected copied code block content, got %q", copied) - } - if !strings.Contains(app.state.StatusText, "Copied code block #1") { - t.Fatalf("expected copy success status, got %q", app.state.StatusText) - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + 60, - Y: y + targetY, - Button: tea.MouseButtonLeft, - Action: tea.MouseActionRelease, - Type: tea.MouseRelease, - }); handled { - t.Fatalf("expected release outside copy button text to be ignored") - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Button: tea.MouseButtonLeft, - Action: tea.MouseActionMotion, - Type: tea.MouseMotion, - }); handled { - t.Fatalf("expected hover/motion over copy button to be ignored") - } -} - -func TestTranscriptMouseCopyFailureSetsError(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - originalClipboardWrite := clipboardWriteAll - t.Cleanup(func() { clipboardWriteAll = originalClipboardWrite }) - - clipboardWriteAll = func(text string) error { - return errors.New("clipboard unavailable") - } - - app.width = 128 - app.height = 40 - app.activeMessages = []providertypes.Message{ - {Role: roleAssistant, Content: "```txt\nhello\n```"}, - } - app.applyComponentLayout(true) - app.rebuildTranscript() - - x, y, _, _ := app.transcriptBounds() - lines := strings.Split(stripANSI(app.transcript.View()), "\n") - targetY := -1 - targetX := -1 - for i, line := range lines { - col := strings.Index(line, "[Copy code #1]") - if col >= 0 { - targetY = i - targetX = col - break - } - } - if targetY < 0 || targetX < 0 { - t.Fatalf("expected visible copy button in transcript view") - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Button: tea.MouseButtonLeft, - }); !handled { - t.Fatalf("expected mouse press on copy button to be handled") - } - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Action: tea.MouseActionRelease, - Type: tea.MouseRelease, - }); !handled { - t.Fatalf("expected mouse release on copy button to be handled") - } - - if app.state.StatusText != statusCodeCopyError || app.state.ExecutionError == "" { - t.Fatalf("expected copy failure status/error, got status=%q err=%q", app.state.StatusText, app.state.ExecutionError) - } -} - -func providerMessage(role, content string) providertypes.Message { - return providertypes.Message{Role: role, Content: content} -} diff --git a/internal/tui/app.go b/internal/tui/core/app/app.go similarity index 50% rename from internal/tui/app.go rename to internal/tui/core/app/app.go index 19816f03..f290ce30 100644 --- a/internal/tui/app.go +++ b/internal/tui/core/app/app.go @@ -1,7 +1,7 @@ package tui import ( - "fmt" + "context" "time" "github.com/charmbracelet/bubbles/filepicker" @@ -17,60 +17,124 @@ import ( "neo-code/internal/config" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" + tuibootstrap "neo-code/internal/tui/bootstrap" + tuistate "neo-code/internal/tui/state" ) +type panel = tuistate.Panel + +const ( + panelSessions panel = tuistate.PanelSessions + panelTranscript panel = tuistate.PanelTranscript + panelActivity panel = tuistate.PanelActivity + panelInput panel = tuistate.PanelInput +) + +type pickerMode = tuistate.PickerMode + +const ( + pickerNone pickerMode = tuistate.PickerNone + pickerProvider pickerMode = tuistate.PickerProvider + pickerModel pickerMode = tuistate.PickerModel + pickerFile pickerMode = tuistate.PickerFile +) + +type RuntimeMsg = tuistate.RuntimeMsg +type RuntimeClosedMsg = tuistate.RuntimeClosedMsg +type runFinishedMsg = tuistate.RunFinishedMsg +type modelCatalogRefreshMsg = tuistate.ModelCatalogRefreshMsg +type compactFinishedMsg = tuistate.CompactFinishedMsg +type localCommandResultMsg = tuistate.LocalCommandResultMsg +type sessionWorkdirResultMsg = tuistate.SessionWorkdirResultMsg +type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg + +type ProviderController interface { + ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) + SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) + ListModels(ctx context.Context) ([]config.ModelDescriptor, error) + ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) + SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) +} + +// appServices 聚合 App 需要的服务依赖,避免与渲染状态混在同一层级。 +type appServices struct { + configManager *config.Manager + providerSvc ProviderController + runtime agentruntime.Runtime +} + +// appComponents 聚合 Bubble Tea 组件与渲染器。 +type appComponents struct { + keys keyMap + help help.Model + spinner spinner.Model + sessions list.Model + commandMenu list.Model + commandMenuMeta tuistate.CommandMenuMeta + providerPicker list.Model + modelPicker list.Model + fileBrowser filepicker.Model + progress progress.Model + transcript viewport.Model + activity viewport.Model + input textarea.Model + markdownRenderer markdownContentRenderer +} + +// appRuntimeState 聚合运行期易变字段,降低 App 顶层字段密度。 +type appRuntimeState struct { + codeCopyBlocks map[int]string + pendingCopyID int + nowFn func() time.Time + lastInputEditAt time.Time + lastPasteLikeAt time.Time + inputBurstStart time.Time + inputBurstCount int + pasteMode bool + activeMessages []providertypes.Message + activities []tuistate.ActivityEntry + fileCandidates []string + modelRefreshID string + focus panel + runProgressValue float64 + runProgressKnown bool + runProgressLabel string +} + type App struct { - state UIState - configManager *config.Manager - providerSvc ProviderController - runtime agentruntime.Runtime - keys keyMap - help help.Model - spinner spinner.Model - sessions list.Model - commandMenu list.Model - commandMenuMeta commandMenuMeta - providerPicker list.Model - modelPicker list.Model - fileBrowser filepicker.Model - progress progress.Model - transcript viewport.Model - activity viewport.Model - input textarea.Model - markdownRenderer markdownContentRenderer - codeCopyBlocks map[int]string - pendingCopyID int - nowFn func() time.Time - lastInputEditAt time.Time - lastPasteLikeAt time.Time - inputBurstStart time.Time - inputBurstCount int - pasteMode bool - pendingPermission *pendingPermissionPrompt - activeMessages []providertypes.Message - activities []activityEntry - fileCandidates []string - modelRefreshID string - focus panel - runProgressValue float64 - runProgressKnown bool - runProgressLabel string - width int - height int - styles styles + state tuistate.UIState + appServices + appComponents + appRuntimeState + width int + height int + styles styles } func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime, providerSvc ProviderController) (App, error) { - if configManager == nil { - return App{}, fmt.Errorf("tui: config manager is nil") - } - if providerSvc == nil { - return App{}, fmt.Errorf("tui: provider service is nil") - } - if cfg == nil { - snapshot := configManager.Get() - cfg = &snapshot + return NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: configManager, + Runtime: runtime, + ProviderService: providerSvc, + }) +} + +// NewWithBootstrap 通过 bootstrap 层完成依赖装配,再构建可运行的 TUI App。 +func NewWithBootstrap(options tuibootstrap.Options) (App, error) { + container, err := tuibootstrap.Build(options) + if err != nil { + return App{}, err } + return newApp(container) +} + +// newApp 根据 bootstrap 装配结果初始化 App 状态与组件。 +func newApp(container tuibootstrap.Container) (App, error) { + cfg := container.Config + configManager := container.ConfigManager + runtime := container.Runtime + providerSvc := container.ProviderService uiStyles := newStyles() markdownRenderer, err := newMarkdownRenderer() @@ -136,7 +200,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime progressBar.Width = 22 app := App{ - state: UIState{ + state: tuistate.UIState{ StatusText: statusReady, CurrentProvider: cfg.SelectedProvider, CurrentModel: cfg.CurrentModel, @@ -144,28 +208,34 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime ActiveSessionTitle: draftSessionTitle, Focus: panelInput, }, - configManager: configManager, - providerSvc: providerSvc, - runtime: runtime, - keys: keys, - help: h, - spinner: spin, - sessions: sessionList, - commandMenu: commandMenu, - providerPicker: newSelectionPickerItems(nil), - modelPicker: newSelectionPickerItems(nil), - fileBrowser: fileBrowser, - progress: progressBar, - transcript: viewport.New(0, 0), - activity: viewport.New(0, 0), - input: input, - markdownRenderer: markdownRenderer, - codeCopyBlocks: make(map[int]string), - nowFn: time.Now, - focus: panelInput, - width: 128, - height: 40, - styles: uiStyles, + appServices: appServices{ + configManager: configManager, + providerSvc: providerSvc, + runtime: runtime, + }, + appComponents: appComponents{ + keys: keys, + help: h, + spinner: spin, + sessions: sessionList, + commandMenu: commandMenu, + providerPicker: newSelectionPickerItems(nil), + modelPicker: newSelectionPickerItems(nil), + fileBrowser: fileBrowser, + progress: progressBar, + transcript: viewport.New(0, 0), + activity: viewport.New(0, 0), + input: input, + markdownRenderer: markdownRenderer, + }, + appRuntimeState: appRuntimeState{ + codeCopyBlocks: make(map[int]string), + nowFn: time.Now, + focus: panelInput, + }, + width: 128, + height: 40, + styles: uiStyles, } if err := app.refreshSessions(); err != nil { diff --git a/internal/tui/command_menu.go b/internal/tui/core/app/command_menu.go similarity index 58% rename from internal/tui/command_menu.go rename to internal/tui/core/app/command_menu.go index b05ae8c9..9e593b79 100644 --- a/internal/tui/command_menu.go +++ b/internal/tui/core/app/command_menu.go @@ -1,11 +1,18 @@ package tui import ( + "fmt" + "io" "path/filepath" "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + + agentsession "neo-code/internal/session" + tuicomponents "neo-code/internal/tui/components" + tuiutils "neo-code/internal/tui/core/utils" + tuistate "neo-code/internal/tui/state" ) const ( @@ -13,18 +20,142 @@ const ( commandMenuBrowse = "@ browse files..." ) +type commandMenuItem struct { + title string + description string + filter string + highlight bool + replacement string + useReplaceRange bool + replaceStart int + replaceEnd int + openFileBrowser bool +} + +func (c commandMenuItem) Title() string { + return c.title +} + +func (c commandMenuItem) Description() string { + return c.description +} + +func (c commandMenuItem) FilterValue() string { + base := strings.TrimSpace(c.filter) + if base != "" { + return strings.ToLower(base) + } + return strings.ToLower(c.title + " " + c.description) +} + +type commandMenuDelegate struct { + styles styles +} + +func (d commandMenuDelegate) Height() int { + return 1 +} + +func (d commandMenuDelegate) Spacing() int { + return 0 +} + +func (d commandMenuDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + return nil +} + +func (d commandMenuDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + entry, ok := item.(commandMenuItem) + if !ok { + return + } + fmt.Fprint(w, tuicomponents.RenderCommandMenuRow(tuicomponents.CommandMenuRowData{ + Title: entry.title, + Description: entry.description, + Highlight: entry.highlight, + Selected: index == m.Index(), + Width: m.Width(), + UsageStyle: d.styles.commandUsage, + UsageMatchStyle: d.styles.commandUsageMatch, + DescriptionStyle: d.styles.commandDesc, + })) +} + +type sessionItem struct { + Summary agentsession.Summary + Active bool +} + +func (s sessionItem) FilterValue() string { + return strings.ToLower(s.Summary.Title) +} + +type selectionItem struct { + id string + name string + description string +} + +func (s selectionItem) Title() string { + return s.name +} + +func (s selectionItem) Description() string { + return s.description +} + +func (s selectionItem) FilterValue() string { + return strings.ToLower(s.id + " " + s.name + " " + s.description) +} + +type sessionDelegate struct { + styles styles +} + +func (d sessionDelegate) Height() int { + return 3 +} + +func (d sessionDelegate) Spacing() int { + return 1 +} + +func (d sessionDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + return nil +} + +func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + session, ok := item.(sessionItem) + if !ok { + return + } + fmt.Fprint(w, tuicomponents.RenderSessionRow(tuicomponents.SessionRowData{ + Title: session.Summary.Title, + UpdatedAtLabel: session.Summary.UpdatedAt.Format("01-02 15:04"), + Active: session.Active, + Selected: index == m.Index(), + Width: m.Width(), + RowStyle: d.styles.sessionRow, + RowActiveStyle: d.styles.sessionRowActive, + RowFocusStyle: d.styles.sessionRowFocused, + MetaStyle: d.styles.sessionMeta, + MetaActiveStyle: d.styles.sessionMetaActive, + MetaFocusStyle: d.styles.sessionMetaFocus, + })) +} + func (a *App) refreshCommandMenu() { input := a.input.Value() if a.state.ActivePicker != pickerNone { a.commandMenu.SetItems(nil) - a.commandMenuMeta = commandMenuMeta{} + a.commandMenuMeta = tuistate.CommandMenuMeta{} return } items, meta := a.buildCommandMenuItems(input, a.transcript.Width) if len(items) == 0 { a.commandMenu.SetItems(nil) - a.commandMenuMeta = commandMenuMeta{} + a.commandMenuMeta = tuistate.CommandMenuMeta{} return } @@ -50,13 +181,13 @@ func (a *App) refreshCommandMenu() { func (a *App) resizeCommandMenu() { width := max(24, a.transcript.Width) - rows := clamp(len(a.commandMenu.Items()), 0, maxCommandMenuRows) + rows := tuiutils.Clamp(len(a.commandMenu.Items()), 0, maxCommandMenuRows) a.commandMenu.SetSize(max(16, width-4), max(1, rows)) } -func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, commandMenuMeta) { +func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, tuistate.CommandMenuMeta) { if suggestions := a.fileMenuSuggestions(input); len(suggestions) > 0 { - return suggestions, commandMenuMeta{Title: fileMenuTitle} + return suggestions, tuistate.CommandMenuMeta{Title: fileMenuTitle} } trimmed := strings.TrimSpace(input) @@ -64,7 +195,7 @@ func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, replacement := trimmed item := commandMenuItem{ title: workspaceCommandUsage, - description: trimMiddle(a.state.CurrentWorkdir, max(24, width-28)), + description: tuiutils.TrimMiddle(a.state.CurrentWorkdir, max(24, width-28)), highlight: true, replacement: replacement, } @@ -75,12 +206,12 @@ func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, item.replaceStart = start item.replaceEnd = end } - return []commandMenuItem{item}, commandMenuMeta{Title: shellMenuTitle} + return []commandMenuItem{item}, tuistate.CommandMenuMeta{Title: shellMenuTitle} } suggestions := a.matchingSlashCommands(trimmed) if len(suggestions) == 0 { - return nil, commandMenuMeta{} + return nil, tuistate.CommandMenuMeta{} } start, end, _, _ := tokenRange(input, tokenSelectorFirst) @@ -97,7 +228,7 @@ func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, replaceEnd: end, }) } - return items, commandMenuMeta{Title: commandMenuTitle} + return items, tuistate.CommandMenuMeta{Title: commandMenuTitle} } func (a App) fileMenuSuggestions(input string) []commandMenuItem { diff --git a/internal/tui/core/app/command_menu_test.go b/internal/tui/core/app/command_menu_test.go new file mode 100644 index 00000000..fa1ea274 --- /dev/null +++ b/internal/tui/core/app/command_menu_test.go @@ -0,0 +1,82 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestCommandMenuItem(t *testing.T) { + item := commandMenuItem{ + title: "Test Command", + description: "Test description", + filter: "test", + highlight: false, + replacement: "/test", + useReplaceRange: false, + replaceStart: 0, + replaceEnd: 0, + openFileBrowser: false, + } + + if item.Title() != "Test Command" { + t.Errorf("Title() = %v, want Test Command", item.Title()) + } + if item.Description() != "Test description" { + t.Errorf("Description() = %v, want Test description", item.Description()) + } + if item.FilterValue() != "test" { + t.Errorf("FilterValue() = %v, want test", item.FilterValue()) + } +} + +func TestCommandMenuItemWithEmptyFilter(t *testing.T) { + item := commandMenuItem{ + title: "Command", + description: "Description", + filter: "", + } + + if item.FilterValue() != "command description" { + t.Errorf("FilterValue() = %v, want command description", item.FilterValue()) + } +} + +func TestCommandMenuItemFilterValueCase(t *testing.T) { + item := commandMenuItem{ + title: "UPPERCASE", + description: "Description", + filter: "lowercase", + } + + if !strings.Contains(item.FilterValue(), "lowercase") { + t.Errorf("FilterValue() should contain lowercase, got %v", item.FilterValue()) + } +} + +func TestSelectionItem(t *testing.T) { + item := selectionItem{ + id: "test-id", + name: "Test Name", + description: "Test description", + } + + if item.Title() != "Test Name" { + t.Errorf("Title() = %v, want Test Name", item.Title()) + } + if item.Description() != "Test description" { + t.Errorf("Description() = %v, want Test description", item.Description()) + } + if !strings.Contains(item.FilterValue(), "test-id") { + t.Errorf("FilterValue() should contain test-id, got %v", item.FilterValue()) + } +} + +func TestCommandMenuView(t *testing.T) { + styles := newStyles() + model := newCommandMenuModel(styles) + + v := model.View() + if v == "" { + t.Error("View() returned empty string") + } +} diff --git a/internal/tui/commands.go b/internal/tui/core/app/commands.go similarity index 66% rename from internal/tui/commands.go rename to internal/tui/core/app/commands.go index a79c44f4..ad373587 100644 --- a/internal/tui/commands.go +++ b/internal/tui/core/app/commands.go @@ -9,6 +9,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + tuicommands "neo-code/internal/tui/core/commands" + tuistatus "neo-code/internal/tui/core/status" + tuiservices "neo-code/internal/tui/services" ) const ( @@ -91,30 +94,8 @@ const ( statusCodeCopyError = "Failed to copy code block" ) -type slashCommand struct { - Usage string - Description string -} - -type commandSuggestion struct { - Command slashCommand - Match bool -} - -type statusSnapshot struct { - ActiveSessionID string - ActiveSessionTitle string - IsAgentRunning bool - IsCompacting bool - CurrentProvider string - CurrentModel string - CurrentWorkdir string - CurrentTool string - ExecutionError string - FocusLabel string - PickerLabel string - MessageCount int -} +type slashCommand = tuicommands.SlashCommand +type commandSuggestion = tuicommands.CommandSuggestion var builtinSlashCommands = []slashCommand{ {Usage: slashUsageHelp, Description: "Show slash command help"}, @@ -256,90 +237,77 @@ func (a *App) selectCurrentModel(modelID string) { } func (a App) matchingSlashCommands(input string) []commandSuggestion { - if !strings.HasPrefix(input, slashPrefix) { - return nil - } - - query := strings.ToLower(strings.TrimSpace(input)) - if isCompleteSlashCommand(query) { - return nil - } - out := make([]commandSuggestion, 0, len(builtinSlashCommands)) - for _, command := range builtinSlashCommands { - normalized := strings.ToLower(command.Usage) - match := query == slashPrefix || strings.HasPrefix(normalized, query) - if query == slashPrefix || match || strings.Contains(normalized, query) { - out = append(out, commandSuggestion{Command: command, Match: match}) - } - } - return out + return tuicommands.MatchSlashCommands(input, slashPrefix, builtinSlashCommands) } func isCompleteSlashCommand(input string) bool { - for _, command := range builtinSlashCommands { - if strings.EqualFold(strings.TrimSpace(command.Usage), strings.TrimSpace(input)) { - return true - } - } - return false + return tuicommands.IsCompleteSlashCommand(input, builtinSlashCommands) } func runProviderSelection(providerSvc ProviderController, providerName string) tea.Cmd { - return func() tea.Msg { - selection, err := providerSvc.SelectProvider(context.Background(), providerName) - if err != nil { - return localCommandResultMsg{err: err} - } - return localCommandResultMsg{ - notice: fmt.Sprintf("[System] Current provider switched to %s.", selection.ProviderID), - providerChanged: true, - } - } + return tuiservices.SelectProviderCmd( + providerSvc, + providerName, + func(selection config.ProviderSelection, err error) tea.Msg { + if err != nil { + return localCommandResultMsg{Err: err} + } + return localCommandResultMsg{ + Notice: fmt.Sprintf("[System] Current provider switched to %s.", selection.ProviderID), + ProviderChanged: true, + } + }, + ) } func runModelSelection(providerSvc ProviderController, modelID string) tea.Cmd { - return func() tea.Msg { - selection, err := providerSvc.SetCurrentModel(context.Background(), modelID) - if err != nil { - return localCommandResultMsg{err: err} - } - return localCommandResultMsg{ - notice: fmt.Sprintf("[System] Current model switched to %s.", selection.ModelID), - modelChanged: true, - } - } -} - -func runLocalCommand(configManager *config.Manager, providerSvc ProviderController, snapshot statusSnapshot, raw string) tea.Cmd { - return func() tea.Msg { - notice, err := executeLocalCommand(context.Background(), configManager, providerSvc, snapshot, raw) - result := localCommandResultMsg{notice: notice, err: err} - if err == nil { - cfg := configManager.Get() - result.providerChanged = !strings.EqualFold(snapshot.CurrentProvider, cfg.SelectedProvider) - result.modelChanged = !strings.EqualFold(snapshot.CurrentModel, cfg.CurrentModel) - } - return result - } + return tuiservices.SelectModelCmd( + providerSvc, + modelID, + func(selection config.ProviderSelection, err error) tea.Msg { + if err != nil { + return localCommandResultMsg{Err: err} + } + return localCommandResultMsg{ + Notice: fmt.Sprintf("[System] Current model switched to %s.", selection.ModelID), + ModelChanged: true, + } + }, + ) +} + +func runLocalCommand(configManager *config.Manager, providerSvc ProviderController, snapshot tuistatus.Snapshot, raw string) tea.Cmd { + return tuiservices.RunLocalCommandCmd( + func(ctx context.Context) (string, error) { + return executeLocalCommand(ctx, configManager, providerSvc, snapshot, raw) + }, + func(notice string, err error) tea.Msg { + result := localCommandResultMsg{Notice: notice, Err: err} + if err == nil { + cfg := configManager.Get() + result.ProviderChanged = !strings.EqualFold(snapshot.CurrentProvider, cfg.SelectedProvider) + result.ModelChanged = !strings.EqualFold(snapshot.CurrentModel, cfg.CurrentModel) + } + return result + }, + ) } func runModelCatalogRefresh(providerSvc ProviderController, providerID string) tea.Cmd { - providerID = strings.TrimSpace(providerID) - if providerSvc == nil || providerID == "" { - return nil - } - - return func() tea.Msg { - models, err := providerSvc.ListModels(context.Background()) - return modelCatalogRefreshMsg{ - providerID: providerID, - models: models, - err: err, - } - } -} - -func executeLocalCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, snapshot statusSnapshot, raw string) (string, error) { + return tuiservices.RefreshModelCatalogCmd( + providerSvc, + providerID, + func(providerID string, models []config.ModelDescriptor, err error) tea.Msg { + return modelCatalogRefreshMsg{ + ProviderID: providerID, + Models: models, + Err: err, + } + }, + ) +} + +func executeLocalCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, snapshot tuistatus.Snapshot, raw string) (string, error) { fields := strings.Fields(strings.TrimSpace(raw)) if len(fields) == 0 { return "", fmt.Errorf("empty command") @@ -357,47 +325,8 @@ func executeLocalCommand(ctx context.Context, configManager *config.Manager, pro } } -func executeStatusCommand(snapshot statusSnapshot) string { - sessionID := snapshot.ActiveSessionID - if strings.TrimSpace(sessionID) == "" { - sessionID = "" - } - sessionTitle := snapshot.ActiveSessionTitle - if strings.TrimSpace(sessionTitle) == "" { - sessionTitle = draftSessionTitle - } - running := "no" - if snapshot.IsAgentRunning || snapshot.IsCompacting { - running = "yes" - } - currentTool := snapshot.CurrentTool - if strings.TrimSpace(currentTool) == "" { - currentTool = "" - } - errorText := snapshot.ExecutionError - if strings.TrimSpace(errorText) == "" { - errorText = "" - } - picker := snapshot.PickerLabel - if strings.TrimSpace(picker) == "" { - picker = "none" - } - - lines := []string{ - "Status:", - "Session: " + sessionTitle, - "Session ID: " + sessionID, - "Running: " + running, - "Provider: " + snapshot.CurrentProvider, - "Model: " + snapshot.CurrentModel, - "Workdir: " + snapshot.CurrentWorkdir, - "Focus: " + snapshot.FocusLabel, - "Picker: " + picker, - "Current Tool: " + currentTool, - fmt.Sprintf("Messages: %d", snapshot.MessageCount), - "Error: " + errorText, - } - return strings.Join(lines, "\n") +func executeStatusCommand(snapshot tuistatus.Snapshot) string { + return tuistatus.Format(snapshot, draftSessionTitle) } func executeProviderCommand(ctx context.Context, providerSvc ProviderController, value string) (string, error) { @@ -421,28 +350,13 @@ func slashHelpText() string { } func splitFirstWord(input string) (string, string) { - input = strings.TrimSpace(input) - if input == "" { - return "", "" - } - index := strings.IndexAny(input, " \t") - if index < 0 { - return input, "" - } - return input[:index], strings.TrimSpace(input[index+1:]) + return tuicommands.SplitFirstWord(input) } func isWorkspaceSlashCommand(raw string) bool { - command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(raw))) - return command == slashCommandCWD + return tuicommands.IsWorkspaceSlashCommand(raw, slashCommandCWD) } func parseWorkspaceSlashCommand(raw string) (string, error) { - command, args := splitFirstWord(strings.TrimSpace(raw)) - switch strings.ToLower(command) { - case slashCommandCWD: - return strings.TrimSpace(args), nil - default: - return "", fmt.Errorf("unknown command %q", command) - } + return tuicommands.ParseWorkspaceSlashCommand(raw, slashCommandCWD) } diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go new file mode 100644 index 00000000..cef5e8b4 --- /dev/null +++ b/internal/tui/core/app/commands_test.go @@ -0,0 +1,144 @@ +package tui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/list" +) + +func TestBuiltinSlashCommands(t *testing.T) { + if len(builtinSlashCommands) == 0 { + t.Error("builtinSlashCommands should not be empty") + } + + found := false + for _, cmd := range builtinSlashCommands { + if cmd.Usage == slashUsageHelp { + found = true + break + } + } + if !found { + t.Error("expected to find /help command") + } +} + +func TestNewSelectionPicker(t *testing.T) { + items := []list.Item{ + selectionItem{id: "1", name: "Item 1", description: "Desc 1"}, + } + picker := newSelectionPicker(items) + _ = picker +} + +func TestNewSelectionPickerItems(t *testing.T) { + items := []selectionItem{ + {id: "1", name: "Item 1", description: "Desc 1"}, + } + picker := newSelectionPickerItems(items) + _ = picker +} + +func TestNewCommandMenuModel(t *testing.T) { + uiStyles := newStyles() + delegate := commandMenuDelegate{styles: uiStyles} + if delegate.Height() == 0 { + t.Error("delegate should have height") + } +} + +func TestStatusConstants(t *testing.T) { + tests := []struct { + name string + value string + }{ + {"statusReady", statusReady}, + {"statusThinking", statusThinking}, + {"statusCanceling", statusCanceling}, + {"statusCanceled", statusCanceled}, + {"statusRunningTool", statusRunningTool}, + {"statusToolFinished", statusToolFinished}, + {"statusToolError", statusToolError}, + {"statusError", statusError}, + {"statusDraft", statusDraft}, + {"statusRunning", statusRunning}, + {"statusApplyingCommand", statusApplyingCommand}, + {"statusRunningCommand", statusRunningCommand}, + {"statusCommandDone", statusCommandDone}, + {"statusCompacting", statusCompacting}, + {"statusChooseProvider", statusChooseProvider}, + {"statusChooseModel", statusChooseModel}, + {"statusBrowseFile", statusBrowseFile}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value == "" { + t.Error("status constant should not be empty") + } + }) + } +} + +func TestFocusLabels(t *testing.T) { + if focusLabelSessions == "" { + t.Error("focusLabelSessions should not be empty") + } + if focusLabelTranscript == "" { + t.Error("focusLabelTranscript should not be empty") + } + if focusLabelActivity == "" { + t.Error("focusLabelActivity should not be empty") + } + if focusLabelComposer == "" { + t.Error("focusLabelComposer should not be empty") + } +} + +func TestMessageTags(t *testing.T) { + if messageTagUser == "" { + t.Error("messageTagUser should not be empty") + } + if messageTagAgent == "" { + t.Error("messageTagAgent should not be empty") + } + if messageTagTool == "" { + t.Error("messageTagTool should not be empty") + } +} + +func TestRoleConstants(t *testing.T) { + if roleUser == "" { + t.Error("roleUser should not be empty") + } + if roleAssistant == "" { + t.Error("roleAssistant should not be empty") + } + if roleTool == "" { + t.Error("roleTool should not be empty") + } +} + +func TestCopyCodeButton(t *testing.T) { + if copyCodeButton == "" { + t.Error("copyCodeButton should not be empty") + } +} + +func TestStatusCodeCopied(t *testing.T) { + if statusCodeCopied == "" { + t.Error("statusCodeCopied should not be empty") + } +} + +func TestStatusCodeCopyError(t *testing.T) { + if statusCodeCopyError == "" { + t.Error("statusCodeCopyError should not be empty") + } +} + +func TestMaxActivityEntries(t *testing.T) { + if maxActivityEntries == 0 { + t.Error("maxActivityEntries should not be zero") + } +} diff --git a/internal/tui/copy_code.go b/internal/tui/core/app/copy_code.go similarity index 96% rename from internal/tui/copy_code.go rename to internal/tui/core/app/copy_code.go index b92b87fa..2bdb89ad 100644 --- a/internal/tui/copy_code.go +++ b/internal/tui/core/app/copy_code.go @@ -6,9 +6,9 @@ import ( "strconv" "strings" - "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + tuiinfra "neo-code/internal/tui/infra" ) type copyCodeButtonBinding struct { @@ -32,7 +32,8 @@ type markdownSegment struct { var ( copyCodeButtonPattern = regexp.MustCompile(`\[Copy code #([0-9]+)\]`) - clipboardWriteAll = clipboard.WriteAll + copyCodeANSIPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) + clipboardWriteAll = tuiinfra.CopyText ) func splitMarkdownSegments(content string) []markdownSegment { @@ -233,7 +234,7 @@ func (a *App) setCodeCopyBlocks(bindings []copyCodeButtonBinding) { } func parseCopyCodeButton(line string) (id int, startCol int, endCol int, ok bool) { - clean := ansiEscapePattern.ReplaceAllString(line, "") + clean := copyCodeANSIPattern.ReplaceAllString(line, "") matches := copyCodeButtonPattern.FindStringSubmatchIndex(clean) if len(matches) < 4 { return 0, 0, 0, false diff --git a/internal/tui/input_features.go b/internal/tui/core/app/input_features.go similarity index 51% rename from internal/tui/input_features.go rename to internal/tui/core/app/input_features.go index 154f195f..c0a99795 100644 --- a/internal/tui/input_features.go +++ b/internal/tui/core/app/input_features.go @@ -1,23 +1,16 @@ package tui import ( - "bytes" "context" - "errors" "fmt" - "io/fs" - "os/exec" "path/filepath" - "regexp" - "sort" "strings" - "time" - "unicode" - "unicode/utf16" tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + tuiinfra "neo-code/internal/tui/infra" + tuiservices "neo-code/internal/tui/services" ) const ( @@ -30,12 +23,6 @@ const ( maxFileSuggestions = 6 ) -type workspaceCommandResultMsg struct { - command string - output string - err error -} - type tokenSelector int const ( @@ -45,8 +32,6 @@ const ( var workspaceCommandExecutor = defaultWorkspaceCommandExecutor -var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) - func isWorkspaceCommandInput(input string) bool { return strings.HasPrefix(strings.TrimSpace(input), workspaceCommandPrefix) } @@ -64,14 +49,18 @@ func extractWorkspaceCommand(input string) (string, error) { } func runWorkspaceCommand(configManager *config.Manager, workdir string, raw string) tea.Cmd { - return func() tea.Msg { - command, output, err := executeWorkspaceCommand(context.Background(), configManager, workdir, raw) - return workspaceCommandResultMsg{ - command: command, - output: output, - err: err, - } - } + return tuiservices.RunWorkspaceCommandCmd( + func(ctx context.Context) (string, string, error) { + return executeWorkspaceCommand(ctx, configManager, workdir, raw) + }, + func(command string, output string, err error) tea.Msg { + return workspaceCommandResultMsg{ + Command: command, + Output: output, + Err: err, + } + }, + ) } func executeWorkspaceCommand(ctx context.Context, configManager *config.Manager, workdir string, raw string) (string, string, error) { @@ -86,57 +75,15 @@ func executeWorkspaceCommand(ctx context.Context, configManager *config.Manager, } func defaultWorkspaceCommandExecutor(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { - command = strings.TrimSpace(command) - if command == "" { - return "", errors.New("command is empty") - } - targetWorkdir := strings.TrimSpace(workdir) - if targetWorkdir == "" { - targetWorkdir = cfg.Workdir - } - - timeoutSec := cfg.ToolTimeoutSec - if timeoutSec <= 0 { - timeoutSec = config.DefaultToolTimeoutSec - } - - runCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) - defer cancel() - - args := shellArgs(cfg.Shell, command) - cmd := exec.CommandContext(runCtx, args[0], args[1:]...) - cmd.Dir = targetWorkdir - output, err := cmd.CombinedOutput() - text := sanitizeWorkspaceOutput(output) - - if runCtx.Err() == context.DeadlineExceeded { - return text, fmt.Errorf("command timed out after %ds", timeoutSec) - } - if err != nil { - return text, err - } - if text == "" { - return "(no output)", nil - } - return text, nil + return tuiinfra.DefaultWorkspaceCommandExecutor(ctx, cfg, workdir, command) } func shellArgs(shell string, command string) []string { - switch strings.ToLower(strings.TrimSpace(shell)) { - case "powershell", "pwsh": - return []string{"powershell", "-NoProfile", "-Command", powershellUTF8Command(command)} - case "bash": - return []string{"bash", "-lc", command} - case "sh": - return []string{"sh", "-lc", command} - default: - return []string{"powershell", "-NoProfile", "-Command", powershellUTF8Command(command)} - } + return tuiinfra.ShellArgs(shell, command) } func powershellUTF8Command(command string) string { - utf8Setup := "[Console]::InputEncoding=[System.Text.Encoding]::UTF8; [Console]::OutputEncoding=[System.Text.Encoding]::UTF8; $OutputEncoding=[System.Text.Encoding]::UTF8; chcp 65001 > $null" - return utf8Setup + "; " + command + return tuiinfra.PowerShellUTF8Command(command) } func formatWorkspaceCommandResult(command string, output string, err error) string { @@ -158,97 +105,11 @@ func formatWorkspaceCommandResult(command string, output string, err error) stri } func sanitizeWorkspaceOutput(raw []byte) string { - text := decodeWorkspaceOutput(raw) - text = strings.ToValidUTF8(text, "?") - text = ansiEscapePattern.ReplaceAllString(text, "") - text = strings.ReplaceAll(text, "\r\n", "\n") - text = strings.ReplaceAll(text, "\r", "\n") - text = strings.Map(func(r rune) rune { - switch { - case r == '\n' || r == '\t': - return r - case r < 0x20: - return -1 - default: - return r - } - }, text) - return strings.TrimSpace(text) + return tuiinfra.SanitizeWorkspaceOutput(raw) } func decodeWorkspaceOutput(raw []byte) string { - if len(raw) == 0 { - return "" - } - - switch { - case bytes.HasPrefix(raw, []byte{0xFF, 0xFE}): - return decodeUTF16(raw[2:], true) - case bytes.HasPrefix(raw, []byte{0xFE, 0xFF}): - return decodeUTF16(raw[2:], false) - } - - if len(raw)%2 == 0 { - le := decodeUTF16(raw, true) - be := decodeUTF16(raw, false) - rawText := string(raw) - rawScore := decodedTextScore(rawText) - leScore := decodedTextScore(le) - beScore := decodedTextScore(be) - - bestText := rawText - bestScore := rawScore - if leScore > bestScore { - bestText = le - bestScore = leScore - } - if beScore > bestScore { - bestText = be - } - return bestText - } - - return string(raw) -} - -func decodedTextScore(text string) int { - if text == "" { - return 0 - } - - score := 0 - for _, r := range text { - switch { - case r == '\n' || r == '\r' || r == '\t': - score += 1 - case r == unicode.ReplacementChar: - score -= 6 - case unicode.IsPrint(r): - score += 2 - default: - score -= 3 - } - } - return score -} - -func decodeUTF16(raw []byte, littleEndian bool) string { - if len(raw) < 2 { - return string(raw) - } - if len(raw)%2 != 0 { - raw = raw[:len(raw)-1] - } - - words := make([]uint16, 0, len(raw)/2) - for i := 0; i < len(raw); i += 2 { - if littleEndian { - words = append(words, uint16(raw[i])|uint16(raw[i+1])<<8) - } else { - words = append(words, uint16(raw[i])<<8|uint16(raw[i+1])) - } - } - return string(utf16.Decode(words)) + return tuiinfra.DecodeWorkspaceOutput(raw) } func (a *App) refreshFileCandidates() error { @@ -257,56 +118,15 @@ func (a *App) refreshFileCandidates() error { return err } a.fileCandidates = candidates - if workdir := strings.TrimSpace(a.state.CurrentWorkdir); workdir != "" { - if absolute, absErr := filepath.Abs(workdir); absErr == nil { - a.fileBrowser.CurrentDirectory = absolute - } + if absolute := tuiservices.ResolveWorkspaceDirectory(a.state.CurrentWorkdir); absolute != "" { + a.fileBrowser.CurrentDirectory = absolute } a.refreshCommandMenu() return nil } func collectWorkspaceFiles(root string, limit int) ([]string, error) { - root, err := filepath.Abs(root) - if err != nil { - return nil, err - } - - var ( - candidates []string - limitErr = errors.New("file limit reached") - ) - - err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - - name := d.Name() - if d.IsDir() { - switch name { - case ".git", ".gocache", "node_modules": - return filepath.SkipDir - } - return nil - } - - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - candidates = append(candidates, filepath.ToSlash(rel)) - if limit > 0 && len(candidates) >= limit { - return limitErr - } - return nil - }) - if err != nil && !errors.Is(err, limitErr) { - return nil, err - } - - sort.Strings(candidates) - return candidates, nil + return tuiservices.CollectWorkspaceFiles(root, limit) } func (a App) resolveFileReferenceSuggestions(input string) (start int, end int, query string, suggestions []string, ok bool) { @@ -321,29 +141,7 @@ func (a App) resolveFileReferenceSuggestions(input string) (start int, end int, } func collectFileSuggestionMatches(query string, candidates []string, limit int) []string { - if len(candidates) == 0 || limit <= 0 { - return nil - } - prefixMatches := make([]string, 0, maxFileSuggestions) - containsMatches := make([]string, 0, maxFileSuggestions) - for _, candidate := range candidates { - lower := strings.ToLower(candidate) - switch { - case query == "" || strings.HasPrefix(lower, query): - prefixMatches = append(prefixMatches, candidate) - case strings.Contains(lower, query): - containsMatches = append(containsMatches, candidate) - } - if len(prefixMatches)+len(containsMatches) >= maxFileSuggestions { - break - } - } - - out := append(prefixMatches, containsMatches...) - if len(out) > limit { - out = out[:limit] - } - return out + return tuiservices.SuggestFileMatches(query, candidates, limit) } func tokenRange(input string, selector tokenSelector) (start int, end int, token string, ok bool) { diff --git a/internal/tui/keymap.go b/internal/tui/core/app/keymap.go similarity index 100% rename from internal/tui/keymap.go rename to internal/tui/core/app/keymap.go diff --git a/internal/tui/core/app/markdown_renderer.go b/internal/tui/core/app/markdown_renderer.go new file mode 100644 index 00000000..7666b241 --- /dev/null +++ b/internal/tui/core/app/markdown_renderer.go @@ -0,0 +1,17 @@ +package tui + +import tuiinfra "neo-code/internal/tui/infra" + +const ( + defaultMarkdownStyle = "dark" + defaultMarkdownCacheMax = 128 +) + +type markdownContentRenderer interface { + Render(content string, width int) (string, error) +} + +// newMarkdownRenderer 创建 TUI 使用的 Markdown 渲染器,实际实现下沉到 infra 层。 +func newMarkdownRenderer() (markdownContentRenderer, error) { + return tuiinfra.NewCachedMarkdownRenderer(defaultMarkdownStyle, defaultMarkdownCacheMax, emptyMessageText), nil +} diff --git a/internal/tui/markdown_renderer_test.go b/internal/tui/core/app/markdown_renderer_test.go similarity index 71% rename from internal/tui/markdown_renderer_test.go rename to internal/tui/core/app/markdown_renderer_test.go index ad04a8a1..1f1345e3 100644 --- a/internal/tui/markdown_renderer_test.go +++ b/internal/tui/core/app/markdown_renderer_test.go @@ -4,6 +4,8 @@ import ( "regexp" "strings" "testing" + + tuiinfra "neo-code/internal/tui/infra" ) var markdownTestANSIPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) @@ -14,9 +16,9 @@ func TestNewMarkdownRendererAndRender(t *testing.T) { t.Fatalf("newMarkdownRenderer() error = %v", err) } - renderer, ok := rendererAny.(*glamourMarkdownRenderer) + renderer, ok := rendererAny.(*tuiinfra.CachedMarkdownRenderer) if !ok { - t.Fatalf("expected glamourMarkdownRenderer type, got %T", rendererAny) + t.Fatalf("expected CachedMarkdownRenderer type, got %T", rendererAny) } output, err := renderer.Render("# Title\n\n- one\n- two", 40) @@ -26,11 +28,11 @@ func TestNewMarkdownRendererAndRender(t *testing.T) { if output == "" { t.Fatalf("expected non-empty markdown output") } - if len(renderer.renderers) != 1 { - t.Fatalf("expected one cached term renderer, got %d", len(renderer.renderers)) + if renderer.RendererCount() != 1 { + t.Fatalf("expected one cached term renderer, got %d", renderer.RendererCount()) } - if len(renderer.cache) != 1 { - t.Fatalf("expected one cached render result, got %d", len(renderer.cache)) + if renderer.CacheCount() != 1 { + t.Fatalf("expected one cached render result, got %d", renderer.CacheCount()) } } @@ -39,7 +41,7 @@ func TestMarkdownRendererHandlesEmptyInputAndCacheEviction(t *testing.T) { if err != nil { t.Fatalf("newMarkdownRenderer() error = %v", err) } - renderer := rendererAny.(*glamourMarkdownRenderer) + renderer := rendererAny.(*tuiinfra.CachedMarkdownRenderer) emptyOutput, err := renderer.Render(" \n\t ", 32) if err != nil { @@ -49,15 +51,15 @@ func TestMarkdownRendererHandlesEmptyInputAndCacheEviction(t *testing.T) { t.Fatalf("expected empty message placeholder, got %q", emptyOutput) } - renderer.maxCacheEntries = 1 + renderer.SetMaxCacheEntries(1) if _, err := renderer.Render("first", 20); err != nil { t.Fatalf("Render(first) error = %v", err) } if _, err := renderer.Render("second", 20); err != nil { t.Fatalf("Render(second) error = %v", err) } - if len(renderer.cacheOrder) != 1 || len(renderer.cache) != 1 { - t.Fatalf("expected cache eviction to keep one entry, got order=%d cache=%d", len(renderer.cacheOrder), len(renderer.cache)) + if renderer.CacheOrderCount() != 1 || renderer.CacheCount() != 1 { + t.Fatalf("expected cache eviction to keep one entry, got order=%d cache=%d", renderer.CacheOrderCount(), renderer.CacheCount()) } } @@ -66,7 +68,7 @@ func TestMarkdownRendererCachesByWidth(t *testing.T) { if err != nil { t.Fatalf("newMarkdownRenderer() error = %v", err) } - renderer := rendererAny.(*glamourMarkdownRenderer) + renderer := rendererAny.(*tuiinfra.CachedMarkdownRenderer) text := "plain text" if _, err := renderer.Render(text, 20); err != nil { @@ -75,8 +77,8 @@ func TestMarkdownRendererCachesByWidth(t *testing.T) { if _, err := renderer.Render(text, 50); err != nil { t.Fatalf("Render(width=50) error = %v", err) } - if len(renderer.renderers) != 2 { - t.Fatalf("expected width-specific renderer cache, got %d", len(renderer.renderers)) + if renderer.RendererCount() != 2 { + t.Fatalf("expected width-specific renderer cache, got %d", renderer.RendererCount()) } } @@ -85,7 +87,7 @@ func TestMarkdownRendererPreservesContent(t *testing.T) { if err != nil { t.Fatalf("newMarkdownRenderer() error = %v", err) } - renderer := rendererAny.(*glamourMarkdownRenderer) + renderer := rendererAny.(*tuiinfra.CachedMarkdownRenderer) output, err := renderer.Render("Title\n\n- first item\n- second item", 40) if err != nil { diff --git a/internal/tui/styles.go b/internal/tui/core/app/styles.go similarity index 92% rename from internal/tui/styles.go rename to internal/tui/core/app/styles.go index ed67013a..15262a71 100644 --- a/internal/tui/styles.go +++ b/internal/tui/core/app/styles.go @@ -303,31 +303,6 @@ func wrapCodeBlock(text string, width int) string { return strings.Join(out, "\n") } -func trimRunes(text string, limit int) string { - runes := []rune(text) - if len(runes) <= limit || limit < 4 { - return text - } - return string(runes[:limit-3]) + "..." -} - -func trimMiddle(text string, limit int) string { - runes := []rune(text) - if len(runes) <= limit || limit < 7 { - return text - } - left := (limit - 3) / 2 - right := limit - 3 - left - return string(runes[:left]) + "..." + string(runes[len(runes)-right:]) -} - -func fallback(value string, fallbackValue string) string { - if strings.TrimSpace(value) == "" { - return fallbackValue - } - return value -} - func preview(text string, width int, lines int) string { rawLines := strings.Split(strings.TrimSpace(text), "\n") out := make([]string, 0, lines) @@ -351,12 +326,3 @@ func preview(text string, width int, lines int) string { return joined } -func clamp(value int, minValue int, maxValue int) int { - if value < minValue { - return minValue - } - if value > maxValue { - return maxValue - } - return value -} diff --git a/internal/tui/update.go b/internal/tui/core/app/update.go similarity index 65% rename from internal/tui/update.go rename to internal/tui/core/app/update.go index 8a2a513c..e9face16 100644 --- a/internal/tui/update.go +++ b/internal/tui/core/app/update.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "path/filepath" "strings" "time" @@ -19,49 +18,27 @@ import ( providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" + tuicommands "neo-code/internal/tui/core/commands" + tuistatus "neo-code/internal/tui/core/status" + tuiutils "neo-code/internal/tui/core/utils" + tuiworkspace "neo-code/internal/tui/core/workspace" + tuiservices "neo-code/internal/tui/services" + tuistate "neo-code/internal/tui/state" ) -type RuntimeMsg struct{ Event agentruntime.RuntimeEvent } -type RuntimeClosedMsg struct{} -type runFinishedMsg struct{ err error } -type permissionResolveResultMsg struct { - requestID string - decision agentruntime.PermissionResolutionDecision - err error -} -type modelCatalogRefreshMsg struct { - providerID string - models []config.ModelDescriptor - err error -} - -type compactFinishedMsg struct { - err error -} - -type localCommandResultMsg struct { - notice string - err error - providerChanged bool - modelChanged bool -} -type sessionWorkdirResultMsg struct { - notice string - workdir string - err error -} - const ( - composerMinHeight = 1 - composerMaxHeight = 5 - composerPromptWidth = 2 - mouseWheelStepLines = 3 - pasteBurstWindow = 120 * time.Millisecond - pasteEnterGuard = 180 * time.Millisecond - pasteSessionGuard = 5 * time.Second - pasteBurstThreshold = 12 + composerMinHeight = tuistate.ComposerMinHeight + composerMaxHeight = tuistate.ComposerMaxHeight + composerPromptWidth = tuistate.ComposerPromptWidth + mouseWheelStepLines = tuistate.MouseWheelStepLines + pasteBurstWindow = tuistate.PasteBurstWindow + pasteEnterGuard = tuistate.PasteEnterGuard + pasteSessionGuard = tuistate.PasteSessionGuard + pasteBurstThreshold = tuistate.PasteBurstThreshold ) +var panelOrder = []panel{panelSessions, panelTranscript, panelActivity, panelInput} + func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var spinCmd tea.Cmd @@ -87,6 +64,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) case RuntimeClosedMsg: a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ActiveRunID = "" a.clearRunProgress() a.state.IsCompacting = false if strings.TrimSpace(a.state.StatusText) == "" { @@ -94,18 +74,18 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, tea.Batch(cmds...) case runFinishedMsg: - if typed.err != nil { + if typed.Err != nil { a.state.IsAgentRunning = false - a.pendingPermission = nil + a.state.ActiveRunID = "" a.clearRunProgress() a.state.StreamingReply = false a.state.CurrentTool = "" - if errors.Is(typed.err, context.Canceled) { + if errors.Is(typed.Err, context.Canceled) { a.state.ExecutionError = "" a.state.StatusText = statusCanceled } else { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() } } if !a.state.IsAgentRunning { @@ -114,42 +94,28 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = a.refreshSessions() a.syncActiveSessionTitle() return a, tea.Batch(cmds...) - case permissionResolveResultMsg: - if typed.err != nil { - if a.pendingPermission != nil && strings.EqualFold(strings.TrimSpace(a.pendingPermission.RequestID), strings.TrimSpace(typed.requestID)) { - a.pendingPermission.Submitted = false - } - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() - a.appendActivity("permission", "Submit permission failed", typed.err.Error(), true) - return a, tea.Batch(cmds...) - } - a.state.ExecutionError = "" - a.state.StatusText = "Permission decision submitted" - a.appendActivity("permission", "Submitted permission decision", string(typed.decision), false) - return a, tea.Batch(cmds...) case modelCatalogRefreshMsg: - if strings.EqualFold(a.modelRefreshID, typed.providerID) { + if strings.EqualFold(a.modelRefreshID, typed.ProviderID) { a.modelRefreshID = "" } - if !strings.EqualFold(strings.TrimSpace(a.state.CurrentProvider), strings.TrimSpace(typed.providerID)) { + if !strings.EqualFold(strings.TrimSpace(a.state.CurrentProvider), strings.TrimSpace(typed.ProviderID)) { return a, tea.Batch(cmds...) } - if typed.err != nil { - a.appendActivity("provider", "Failed to refresh models", typed.err.Error(), true) + if typed.Err != nil { + a.appendActivity("provider", "Failed to refresh models", typed.Err.Error(), true) return a, tea.Batch(cmds...) } - replacePickerItems(&a.modelPicker, mapModelItems(typed.models)) + replacePickerItems(&a.modelPicker, mapModelItems(typed.Models)) cfg := a.configManager.Get() a.syncConfigState(cfg) selectPickerItemByID(&a.modelPicker, cfg.CurrentModel) return a, tea.Batch(cmds...) case compactFinishedMsg: a.state.IsCompacting = false - if typed.err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() + if typed.Err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() } if err := a.refreshSessions(); err != nil { a.state.ExecutionError = err.Error() @@ -166,16 +132,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.transcript.GotoBottom() return a, tea.Batch(cmds...) case localCommandResultMsg: - if typed.err != nil { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() - a.appendActivity("command", "Local command failed", typed.err.Error(), true) + if typed.Err != nil { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() + a.appendActivity("command", "Local command failed", typed.Err.Error(), true) } else { a.state.ExecutionError = "" - a.state.StatusText = typed.notice + a.state.StatusText = typed.Notice cfg := a.configManager.Get() a.syncConfigState(cfg) - if typed.providerChanged { + if typed.ProviderChanged { if err := a.refreshProviderPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() @@ -193,42 +159,42 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := a.requestModelCatalogRefresh(cfg.SelectedProvider); cmd != nil { cmds = append(cmds, cmd) } - } else if typed.modelChanged { + } else if typed.ModelChanged { a.selectCurrentModel(cfg.CurrentModel) } - a.appendActivity("command", typed.notice, "", false) + a.appendActivity("command", typed.Notice, "", false) } return a, tea.Batch(cmds...) case sessionWorkdirResultMsg: - if typed.err != nil { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() - a.appendActivity("workspace", "Workspace command failed", typed.err.Error(), true) + if typed.Err != nil { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() + a.appendActivity("workspace", "Workspace command failed", typed.Err.Error(), true) return a, tea.Batch(cmds...) } a.state.ExecutionError = "" - a.state.StatusText = typed.notice - a.state.CurrentWorkdir = strings.TrimSpace(typed.workdir) + a.state.StatusText = typed.Notice + a.state.CurrentWorkdir = strings.TrimSpace(typed.Workdir) if err := a.refreshFileCandidates(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("workspace", "Failed to refresh workspace files", err.Error(), true) return a, tea.Batch(cmds...) } - a.appendActivity("workspace", typed.notice, "", false) + a.appendActivity("workspace", typed.Notice, "", false) return a, tea.Batch(cmds...) case workspaceCommandResultMsg: - if typed.command == "" && typed.err != nil { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() - a.appendActivity("command", "Workspace command failed", typed.err.Error(), true) + if typed.Command == "" && typed.Err != nil { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() + a.appendActivity("command", "Workspace command failed", typed.Err.Error(), true) return a, tea.Batch(cmds...) } - result := formatWorkspaceCommandResult(typed.command, typed.output, typed.err) - if typed.err != nil { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = fmt.Sprintf("Command failed: %s", typed.command) + result := formatWorkspaceCommandResult(typed.Command, typed.Output, typed.Err) + if typed.Err != nil { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = fmt.Sprintf("Command failed: %s", typed.Command) a.appendActivity("command", "Command failed", result, true) } else { a.state.ExecutionError = "" @@ -256,14 +222,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.applyComponentLayout(true) return a, tea.Batch(cmds...) } - if a.pendingPermission != nil { - if permissionCmd, handled := a.handlePermissionDecisionKey(typed); handled { - if permissionCmd != nil { - cmds = append(cmds, permissionCmd) - } - return a, tea.Batch(cmds...) - } - } if a.state.IsAgentRunning && key.Matches(typed, a.keys.CancelAgent) { if a.runtime.CancelActiveRun() { a.state.StatusText = statusCanceling @@ -428,11 +386,10 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.CurrentTool = "" a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleUser, Content: input}) a.rebuildTranscript() - requestedWorkdir := "" - if strings.TrimSpace(a.state.ActiveSessionID) == "" { - requestedWorkdir = a.state.CurrentWorkdir - } - cmds = append(cmds, runAgent(a.runtime, a.state.ActiveSessionID, requestedWorkdir, input)) + runID := fmt.Sprintf("run-%d", a.now().UnixNano()) + a.state.ActiveRunID = runID + requestedWorkdir := tuiutils.RequestedWorkdirForRun(a.state.ActiveSessionID, a.state.CurrentWorkdir) + cmds = append(cmds, runAgent(a.runtime, runID, a.state.ActiveSessionID, requestedWorkdir, input)) return a, tea.Batch(cmds...) } } @@ -642,7 +599,8 @@ func (a *App) refreshMessages() error { a.activeMessages = session.Messages a.clearActivities() a.state.ActiveSessionTitle = session.Title - a.state.CurrentWorkdir = selectSessionWorkdir(session.Workdir, a.configManager.Get().Workdir) + a.state.CurrentWorkdir = tuiworkspace.SelectSessionWorkdir(session.Workdir, a.configManager.Get().Workdir) + a.refreshRuntimeSourceSnapshot() return nil } @@ -688,183 +646,327 @@ func (a *App) syncConfigState(cfg config.Config) { } } +// refreshRuntimeSourceSnapshot 浠?runtime 鏌ヨ context/token/tool 蹇収锛岀敤浜庝細璇濆垏鎹㈡垨鎭㈠鏃跺洖濉?UI銆 +func (a *App) refreshRuntimeSourceSnapshot() { + sessionID := strings.TrimSpace(a.state.ActiveSessionID) + if sessionID != "" { + if source, ok := a.runtime.(runtimeSessionContextSource); ok { + raw, err := source.GetSessionContext(context.Background(), sessionID) + if err == nil { + contextSnapshot, parsed := tuiservices.ParseSessionContextSnapshot(raw) + if parsed { + mapped := tuiservices.MapSessionContextSnapshot(contextSnapshot) + a.state.RunContext.Provider = mapped.Provider + a.state.RunContext.Model = mapped.Model + a.state.RunContext.Workdir = mapped.Workdir + a.state.RunContext.Mode = mapped.Mode + a.state.RunContext.SessionID = mapped.SessionID + } + } + } + if source, ok := a.runtime.(runtimeSessionUsageSource); ok { + raw, err := source.GetSessionUsage(context.Background(), sessionID) + if err == nil { + usageSnapshot, parsed := tuiservices.ParseUsageSnapshot(raw) + if parsed { + a.state.TokenUsage = tuiservices.MapUsageSnapshot(usageSnapshot, a.state.TokenUsage) + } + } + } + } + + runID := strings.TrimSpace(a.state.ActiveRunID) + if runID == "" { + return + } + if source, ok := a.runtime.(runtimeRunSnapshotSource); ok { + raw, err := source.GetRunSnapshot(context.Background(), runID) + if err == nil { + runSnapshot, parsed := tuiservices.ParseRunSnapshot(raw) + if parsed { + contextVM, toolVM, usageVM := tuiservices.MapRunSnapshot(runSnapshot) + if strings.TrimSpace(contextVM.Provider) != "" { + a.state.RunContext = contextVM + } + if len(toolVM) > 0 { + a.state.ToolStates = append([]tuistate.ToolState(nil), toolVM...) + } + a.state.TokenUsage = usageVM + } + } + } +} + +// runtimeSessionContextSource 约束可选的会话上下文查询能力。 +type runtimeSessionContextSource interface { + GetSessionContext(ctx context.Context, sessionID string) (any, error) +} + +// runtimeSessionUsageSource 约束可选的会话 token 使用量查询能力。 +type runtimeSessionUsageSource interface { + GetSessionUsage(ctx context.Context, sessionID string) (any, error) +} + +// runtimeRunSnapshotSource 约束可选的运行快照查询能力。 +type runtimeRunSnapshotSource interface { + GetRunSnapshot(ctx context.Context, runID string) (any, error) +} + +var runtimeEventHandlerRegistry = map[agentruntime.EventType]func(*App, agentruntime.RuntimeEvent) bool{ + agentruntime.EventUserMessage: runtimeEventUserMessageHandler, + agentruntime.EventType(tuiservices.RuntimeEventRunContext): runtimeEventRunContextHandler, + agentruntime.EventType(tuiservices.RuntimeEventToolStatus): runtimeEventToolStatusHandler, + agentruntime.EventType(tuiservices.RuntimeEventUsage): runtimeEventUsageHandler, + agentruntime.EventToolCallThinking: runtimeEventToolCallThinkingHandler, + agentruntime.EventToolStart: runtimeEventToolStartHandler, + agentruntime.EventToolResult: runtimeEventToolResultHandler, + agentruntime.EventAgentChunk: runtimeEventAgentChunkHandler, + agentruntime.EventToolChunk: runtimeEventToolChunkHandler, + agentruntime.EventAgentDone: runtimeEventAgentDoneHandler, + agentruntime.EventRunCanceled: runtimeEventRunCanceledHandler, + agentruntime.EventError: runtimeEventErrorHandler, + agentruntime.EventProviderRetry: runtimeEventProviderRetryHandler, + agentruntime.EventCompactDone: runtimeEventCompactDoneHandler, + agentruntime.EventCompactError: runtimeEventCompactErrorHandler, +} + +// handleRuntimeEvent 通过注册表分发 runtime 事件,避免巨型 switch 膨胀。 func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { if a.state.ActiveSessionID == "" { a.state.ActiveSessionID = event.SessionID } + handler, ok := runtimeEventHandlerRegistry[event.Type] + if !ok { + return false + } + return handler(a, event) +} - transcriptDirty := false +// runtimeEventUserMessageHandler 处理用户消息进入运行队列后的状态同步。 +func runtimeEventUserMessageHandler(a *App, event agentruntime.RuntimeEvent) bool { + if strings.TrimSpace(event.RunID) != "" { + a.state.ActiveRunID = strings.TrimSpace(event.RunID) + } + a.state.StatusText = statusThinking + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ExecutionError = "" + a.setRunProgress(0.15, "Queued") + return false +} - switch event.Type { - case agentruntime.EventUserMessage: - a.state.StatusText = statusThinking - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.state.ExecutionError = "" - a.setRunProgress(0.15, "Queued") - case agentruntime.EventToolCallThinking: - if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { - a.state.CurrentTool = payload - a.setRunProgress(0.35, "Planning") - a.appendActivity("tool", "Planning tool call", payload, false) - } - case agentruntime.EventToolStart: - a.state.StatusText = statusRunningTool - a.state.StreamingReply = false - if payload, ok := event.Payload.(providertypes.ToolCall); ok { - a.state.CurrentTool = payload.Name - a.setRunProgress(0.6, "Running tool") - a.appendActivity("tool", "Running tool", payload.Name, false) - } - case agentruntime.EventToolResult: - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.setRunProgress(0.8, "Integrating result") - if payload, ok := event.Payload.(tools.ToolResult); ok { - a.activeMessages = append(a.activeMessages, providertypes.Message{ - Role: roleTool, - Content: payload.Content, - IsError: payload.IsError, - }) - transcriptDirty = true - if payload.IsError { - a.state.ExecutionError = payload.Content - a.state.StatusText = statusToolError - a.appendActivity("tool", "Tool error", preview(payload.Content, 88, 4), true) - } else if strings.TrimSpace(a.state.ExecutionError) == "" { - a.state.StatusText = statusToolFinished - a.appendActivity("tool", "Completed tool", payload.Name, false) - } - } - case agentruntime.EventAgentChunk: - if payload, ok := event.Payload.(string); ok { - a.appendAssistantChunk(payload) - if !a.runProgressKnown { - a.setRunProgress(0.72, "Generating") - } - transcriptDirty = true - } - case agentruntime.EventToolChunk: - if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { - a.state.StatusText = statusRunningTool - a.appendActivity("tool", "Tool output", preview(payload, 88, 4), false) +// runtimeEventRunContextHandler 处理 runtime 上下文事件并回填界面状态。 +func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := tuiservices.ParseRunContextPayload(event.Payload) + if !ok { + return false + } + mapped := tuiservices.MapRunContextPayload(event.RunID, event.SessionID, payload) + a.state.RunContext = mapped + if strings.TrimSpace(mapped.RunID) != "" { + a.state.ActiveRunID = mapped.RunID + } + if strings.TrimSpace(mapped.Provider) != "" { + a.state.CurrentProvider = mapped.Provider + } + if strings.TrimSpace(mapped.Model) != "" { + a.state.CurrentModel = mapped.Model + } + if strings.TrimSpace(mapped.Workdir) != "" { + a.state.CurrentWorkdir = mapped.Workdir + } + return false +} + +// runtimeEventToolStatusHandler 处理工具状态流转并更新当前工具展示。 +func runtimeEventToolStatusHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := tuiservices.ParseToolStatusPayload(event.Payload) + if !ok { + return false + } + toolVM := tuiservices.MapToolStatusPayload(payload) + a.state.ToolStates = tuiservices.MergeToolStates(a.state.ToolStates, toolVM, 16) + switch toolVM.Status { + case tuistate.ToolLifecyclePlanned, tuistate.ToolLifecycleRunning: + if strings.TrimSpace(toolVM.ToolName) != "" { + a.state.CurrentTool = toolVM.ToolName } - case agentruntime.EventAgentDone: - a.state.IsAgentRunning = false - a.state.StreamingReply = false + case tuistate.ToolLifecycleSucceeded, tuistate.ToolLifecycleFailed: a.state.CurrentTool = "" - a.pendingPermission = nil - a.clearRunProgress() - if strings.TrimSpace(a.state.ExecutionError) == "" { - a.state.StatusText = statusReady - } - if payload, ok := event.Payload.(providertypes.Message); ok && strings.TrimSpace(payload.Content) != "" && !a.lastAssistantMatches(payload.Content) { - a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Content: payload.Content}) - transcriptDirty = true - } - case agentruntime.EventRunCanceled: - a.state.IsAgentRunning = false - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.pendingPermission = nil - a.state.ExecutionError = "" - a.state.StatusText = statusCanceled - a.clearRunProgress() - a.appendActivity("run", "Canceled current run", "", false) - case agentruntime.EventError: - a.state.StatusText = statusError - a.state.IsAgentRunning = false - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.pendingPermission = nil - a.clearRunProgress() - if payload, ok := event.Payload.(string); ok { - a.state.ExecutionError = payload - a.state.StatusText = payload - a.appendActivity("run", "Runtime error", payload, true) - } - case agentruntime.EventProviderRetry: - if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { - a.state.StatusText = statusThinking - a.runProgressKnown = false - a.appendActivity("provider", "Retrying provider call", payload, false) - } - case agentruntime.EventPermissionRequest: - payload, ok := event.Payload.(agentruntime.PermissionRequestPayload) - if !ok { - return transcriptDirty - } - a.pendingPermission = &pendingPermissionPrompt{ - RequestID: strings.TrimSpace(payload.RequestID), - ToolCallID: strings.TrimSpace(payload.ToolCallID), - ToolName: strings.TrimSpace(payload.ToolName), - ToolCategory: strings.TrimSpace(payload.ToolCategory), - Target: strings.TrimSpace(payload.Target), - } - prompt := fmt.Sprintf( - "[Permission] Tool=%s Category=%s Target=%s | y=once, a=always(session), n=reject(session)", - fallback(payload.ToolName, "-"), - fallback(payload.ToolCategory, "-"), - fallback(payload.Target, "-"), - ) - a.state.StatusText = "Permission required (y/a/n)" - a.appendActivity("permission", "Awaiting permission decision", prompt, false) - a.appendInlineMessage(roleSystem, prompt) - transcriptDirty = true - case agentruntime.EventPermissionResolved: - payload, ok := event.Payload.(agentruntime.PermissionResolvedPayload) - if !ok { - return transcriptDirty - } - if a.pendingPermission != nil && strings.TrimSpace(a.pendingPermission.RequestID) != "" && - strings.EqualFold(a.pendingPermission.RequestID, strings.TrimSpace(payload.RequestID)) { - a.pendingPermission = nil - } - resolved := fmt.Sprintf( - "[Permission] %s %s (%s)", - fallback(payload.ToolName, "-"), - fallback(payload.ResolvedAs, "-"), - fallback(payload.RememberScope, "-"), - ) - a.appendActivity("permission", "Permission resolved", resolved, strings.EqualFold(payload.ResolvedAs, "rejected")) - if strings.EqualFold(payload.ResolvedAs, "approved") { - a.state.StatusText = "Permission approved, executing tool..." - } - case agentruntime.EventCompactDone: - payload, ok := event.Payload.(agentruntime.CompactDonePayload) - if !ok { - return transcriptDirty - } - a.state.ExecutionError = "" - a.state.StatusText = fmt.Sprintf("Compact(%s) saved %.1f%% context", payload.TriggerMode, payload.SavedRatio*100) - a.appendInlineMessage( - roleSystem, - fmt.Sprintf( - "[System] Compact(%s) %s (before=%d, after=%d, saved=%.1f%%, transcript=%s)", - payload.TriggerMode, - map[bool]string{true: "applied", false: "checked"}[payload.Applied], - payload.BeforeChars, - payload.AfterChars, - payload.SavedRatio*100, - payload.TranscriptPath, - ), - ) - transcriptDirty = true - case agentruntime.EventCompactError: - payload, ok := event.Payload.(agentruntime.CompactErrorPayload) - if !ok { - return transcriptDirty - } - message := fmt.Sprintf("Compact(%s) failed: %s", payload.TriggerMode, payload.Message) - a.state.ExecutionError = message - a.state.StatusText = message - a.appendInlineMessage(roleError, message) - transcriptDirty = true } + return false +} - return transcriptDirty +// runtimeEventUsageHandler 处理 token 使用量更新。 +func runtimeEventUsageHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := tuiservices.ParseUsagePayload(event.Payload) + if !ok { + return false + } + a.state.TokenUsage = tuiservices.MapUsagePayload(payload) + return false +} + +// runtimeEventToolCallThinkingHandler 处理工具规划阶段事件。 +func runtimeEventToolCallThinkingHandler(a *App, event agentruntime.RuntimeEvent) bool { + if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { + a.state.CurrentTool = payload + a.setRunProgress(0.35, "Planning") + a.appendActivity("tool", "Planning tool call", payload, false) + } + return false } +// runtimeEventToolStartHandler 处理工具开始执行事件。 +func runtimeEventToolStartHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.StatusText = statusRunningTool + a.state.StreamingReply = false + if payload, ok := event.Payload.(providertypes.ToolCall); ok { + a.state.CurrentTool = payload.Name + a.setRunProgress(0.6, "Running tool") + a.appendActivity("tool", "Running tool", payload.Name, false) + } + return false +} + +// runtimeEventToolResultHandler 处理工具执行结果并决定是否刷新对话区。 +func runtimeEventToolResultHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.setRunProgress(0.8, "Integrating result") + payload, ok := event.Payload.(tools.ToolResult) + if !ok { + return false + } + a.activeMessages = append(a.activeMessages, providertypes.Message{ + Role: roleTool, + Content: payload.Content, + IsError: payload.IsError, + }) + if payload.IsError { + a.state.ExecutionError = payload.Content + a.state.StatusText = statusToolError + a.appendActivity("tool", "Tool error", preview(payload.Content, 88, 4), true) + } else if strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.StatusText = statusToolFinished + a.appendActivity("tool", "Completed tool", payload.Name, false) + } + return true +} + +// runtimeEventAgentChunkHandler 处理模型流式增量输出。 +func runtimeEventAgentChunkHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(string) + if !ok { + return false + } + a.appendAssistantChunk(payload) + if !a.runProgressKnown { + a.setRunProgress(0.72, "Generating") + } + return true +} + +// runtimeEventToolChunkHandler 处理工具流式输出片段。 +func runtimeEventToolChunkHandler(a *App, event agentruntime.RuntimeEvent) bool { + if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { + a.state.StatusText = statusRunningTool + a.appendActivity("tool", "Tool output", preview(payload, 88, 4), false) + } + return false +} + +// runtimeEventAgentDoneHandler 处理运行完成事件。 +func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ActiveRunID = "" + a.clearRunProgress() + if strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.StatusText = statusReady + } + if payload, ok := event.Payload.(providertypes.Message); ok && strings.TrimSpace(payload.Content) != "" && !a.lastAssistantMatches(payload.Content) { + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Content: payload.Content}) + return true + } + return false +} + +// runtimeEventRunCanceledHandler 处理运行取消事件。 +func runtimeEventRunCanceledHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ActiveRunID = "" + a.state.ExecutionError = "" + a.state.StatusText = statusCanceled + a.clearRunProgress() + a.appendActivity("run", "Canceled current run", "", false) + return false +} + +// runtimeEventErrorHandler 处理运行时错误事件。 +func runtimeEventErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.StatusText = statusError + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ActiveRunID = "" + a.clearRunProgress() + if payload, ok := event.Payload.(string); ok { + a.state.ExecutionError = payload + a.state.StatusText = payload + a.appendActivity("run", "Runtime error", payload, true) + } + return false +} + +// runtimeEventProviderRetryHandler 处理 provider 重试提示事件。 +func runtimeEventProviderRetryHandler(a *App, event agentruntime.RuntimeEvent) bool { + if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { + a.state.StatusText = statusThinking + a.runProgressKnown = false + a.appendActivity("provider", "Retrying provider call", payload, false) + } + return false +} + +// runtimeEventCompactDoneHandler 处理 compact 完成事件。 +func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(agentruntime.CompactDonePayload) + if !ok { + return false + } + a.state.ExecutionError = "" + a.state.StatusText = fmt.Sprintf("Compact(%s) saved %.1f%% context", payload.TriggerMode, payload.SavedRatio*100) + a.appendInlineMessage( + roleSystem, + fmt.Sprintf( + "[System] Compact(%s) %s (before=%d, after=%d, saved=%.1f%%, transcript=%s)", + payload.TriggerMode, + map[bool]string{true: "applied", false: "checked"}[payload.Applied], + payload.BeforeChars, + payload.AfterChars, + payload.SavedRatio*100, + payload.TranscriptPath, + ), + ) + return true +} + +// runtimeEventCompactErrorHandler 处理 compact 异常事件。 +func runtimeEventCompactErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(agentruntime.CompactErrorPayload) + if !ok { + return false + } + message := fmt.Sprintf("Compact(%s) failed: %s", payload.TriggerMode, payload.Message) + a.state.ExecutionError = message + a.state.StatusText = message + a.appendInlineMessage(roleError, message) + return true +} func (a *App) appendAssistantChunk(chunk string) { if chunk == "" { return @@ -900,7 +1002,7 @@ func (a *App) appendActivity(kind string, title string, detail string, isError b detail = "" } - a.activities = append(a.activities, activityEntry{ + a.activities = append(a.activities, tuistate.ActivityEntry{ Time: time.Now(), Kind: strings.TrimSpace(kind), Title: title, @@ -908,7 +1010,7 @@ func (a *App) appendActivity(kind string, title string, detail string, isError b IsError: isError, }) if len(a.activities) > maxActivityEntries { - a.activities = append([]activityEntry(nil), a.activities[len(a.activities)-maxActivityEntries:]...) + a.activities = append([]tuistate.ActivityEntry(nil), a.activities[len(a.activities)-maxActivityEntries:]...) } a.syncActivityViewport(previousCount) } @@ -1019,7 +1121,7 @@ func (a App) transcriptBounds() (int, int, int, int) { lay := a.computeLayout() contentX := a.styles.doc.GetPaddingLeft() contentY := a.styles.doc.GetPaddingTop() - headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + headerHeight := headerBarHeight bodyY := contentY + headerHeight streamX := contentX @@ -1045,7 +1147,7 @@ func (a App) inputBounds() (int, int, int, int) { lay := a.computeLayout() contentX := a.styles.doc.GetPaddingLeft() contentY := a.styles.doc.GetPaddingTop() - headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + headerHeight := headerBarHeight bodyY := contentY + headerHeight streamX := contentX @@ -1065,7 +1167,7 @@ func (a App) activityBounds() (int, int, int, int) { lay := a.computeLayout() contentX := a.styles.doc.GetPaddingLeft() contentY := a.styles.doc.GetPaddingTop() - headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + headerHeight := headerBarHeight bodyY := contentY + headerHeight streamX := contentX @@ -1173,7 +1275,7 @@ func (a App) shouldHandleTabAsInput(typed tea.KeyMsg) bool { } func (a *App) focusNext() { - order := []panel{panelSessions, panelTranscript, panelActivity, panelInput} + order := panelOrder current := 0 for i, item := range order { if item == a.focus { @@ -1187,7 +1289,7 @@ func (a *App) focusNext() { } func (a *App) focusPrev() { - order := []panel{panelSessions, panelTranscript, panelActivity, panelInput} + order := panelOrder current := 0 for i, item := range order { if item == a.focus { @@ -1249,9 +1351,9 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.activity.Height = 0 } - a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) - a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) - a.fileBrowser.SetHeight(max(6, clamp(lay.rightHeight-8, 8, 16))) + a.providerPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) + a.modelPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) + a.fileBrowser.SetHeight(max(6, tuiutils.Clamp(lay.rightHeight-8, 8, 16))) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { a.rebuildTranscript() } else if a.transcript.AtBottom() || a.isBusy() { @@ -1271,18 +1373,18 @@ func (a App) composerInnerWidth(totalWidth int) int { } func (a App) composerHeight() int { - return clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) + return tuiutils.Clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) } func (a *App) growComposerForNewline() { - nextHeight := clamp(a.input.LineCount()+1, composerMinHeight, composerMaxHeight) + nextHeight := tuiutils.Clamp(a.input.LineCount()+1, composerMinHeight, composerMaxHeight) if nextHeight > a.input.Height() { a.input.SetHeight(nextHeight) } } func (a *App) normalizeComposerHeight() { - targetHeight := clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) + targetHeight := tuiutils.Clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) if targetHeight != a.input.Height() { a.input.SetHeight(targetHeight) } @@ -1398,31 +1500,13 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { } } -func (a App) currentStatusSnapshot() statusSnapshot { - picker := "none" - switch a.state.ActivePicker { - case pickerProvider: - picker = "provider" - case pickerModel: - picker = "model" - case pickerFile: - picker = "file" - } - - return statusSnapshot{ - ActiveSessionID: a.state.ActiveSessionID, - ActiveSessionTitle: a.state.ActiveSessionTitle, - IsAgentRunning: a.state.IsAgentRunning, - IsCompacting: a.state.IsCompacting, - CurrentProvider: a.state.CurrentProvider, - CurrentModel: a.state.CurrentModel, - CurrentWorkdir: a.state.CurrentWorkdir, - CurrentTool: a.state.CurrentTool, - ExecutionError: a.state.ExecutionError, - FocusLabel: a.focusLabel(), - PickerLabel: picker, - MessageCount: len(a.activeMessages), - } +func (a App) currentStatusSnapshot() tuistatus.Snapshot { + return tuistatus.BuildFromUIState( + a.state, + len(a.activeMessages), + a.focusLabel(), + tuiutils.PickerLabelFromMode(a.state.ActivePicker), + ) } func (a *App) startDraftSession() { @@ -1434,6 +1518,10 @@ func (a *App) startDraftSession() { a.state.StatusText = statusDraft a.state.ExecutionError = "" a.state.CurrentTool = "" + a.state.ActiveRunID = "" + a.state.ToolStates = nil + a.state.RunContext = tuistate.ContextWindowState{} + a.state.TokenUsage = tuistate.TokenUsageState{} a.clearRunProgress() a.input.Reset() a.state.InputText = "" @@ -1459,73 +1547,24 @@ func (a *App) requestModelCatalogRefresh(providerID string) tea.Cmd { } func ListenForRuntimeEvent(sub <-chan agentruntime.RuntimeEvent) tea.Cmd { - return func() tea.Msg { - event, ok := <-sub - if !ok { - return RuntimeClosedMsg{} - } - return RuntimeMsg{Event: event} - } + return tuiservices.ListenForRuntimeEventCmd( + sub, + func(event agentruntime.RuntimeEvent) tea.Msg { return RuntimeMsg{Event: event} }, + func() tea.Msg { return RuntimeClosedMsg{} }, + ) } -// handlePermissionDecisionKey 处理待审批状态下的快捷授权键位。 -func (a *App) handlePermissionDecisionKey(msg tea.KeyMsg) (tea.Cmd, bool) { - if a.pendingPermission == nil { - return nil, false - } - - keyText := strings.ToLower(strings.TrimSpace(msg.String())) - var decision agentruntime.PermissionResolutionDecision - switch keyText { - case "y": - decision = agentruntime.PermissionResolutionAllowOnce - case "a": - decision = agentruntime.PermissionResolutionAllowSession - case "n": - decision = agentruntime.PermissionResolutionReject - default: - return nil, false - } - if a.pendingPermission.Submitted { - a.state.StatusText = "Permission decision already submitted, waiting runtime..." - return nil, true - } - - requestID := strings.TrimSpace(a.pendingPermission.RequestID) - a.pendingPermission.Submitted = true - a.state.StatusText = "Submitting permission decision..." - a.state.ExecutionError = "" - return runResolvePermission(a.runtime, requestID, decision), true -} - -func runAgent(runtime agentruntime.Runtime, sessionID string, workdir string, content string) tea.Cmd { - return func() tea.Msg { - err := runtime.Run(context.Background(), agentruntime.UserInput{ +func runAgent(runtime agentruntime.Runtime, runID string, sessionID string, workdir string, content string) tea.Cmd { + return tuiservices.RunAgentCmd( + runtime, + agentruntime.UserInput{ SessionID: sessionID, + RunID: strings.TrimSpace(runID), Content: content, Workdir: workdir, - }) - return runFinishedMsg{err: err} - } -} - -// runResolvePermission 在独立命令中提交权限审批决定,避免阻塞 UI 事件循环。 -func runResolvePermission( - runtime agentruntime.Runtime, - requestID string, - decision agentruntime.PermissionResolutionDecision, -) tea.Cmd { - return func() tea.Msg { - err := runtime.ResolvePermission(context.Background(), agentruntime.PermissionResolutionInput{ - RequestID: requestID, - Decision: decision, - }) - return permissionResolveResultMsg{ - requestID: requestID, - decision: decision, - err: err, - } - } + }, + func(err error) tea.Msg { return runFinishedMsg{Err: err} }, + ) } func runSessionWorkdirCommand( @@ -1535,96 +1574,33 @@ func runSessionWorkdirCommand( raw string, ) tea.Cmd { return func() tea.Msg { - requested, err := parseWorkspaceSlashCommand(raw) - if err != nil { - return sessionWorkdirResultMsg{err: err} - } - if strings.TrimSpace(requested) == "" { - workdir := strings.TrimSpace(currentWorkdir) - if workdir == "" { - return sessionWorkdirResultMsg{err: fmt.Errorf("usage: /cwd ")} - } - return sessionWorkdirResultMsg{ - notice: fmt.Sprintf("[System] Current workspace is %s.", workdir), - workdir: workdir, - } - } - - if strings.TrimSpace(sessionID) == "" { - workdir, err := resolveWorkspacePath(currentWorkdir, requested) - if err != nil { - return sessionWorkdirResultMsg{err: err} - } - return sessionWorkdirResultMsg{ - notice: fmt.Sprintf("[System] Draft workspace switched to %s.", workdir), - workdir: workdir, - } - } - - session, err := runtime.SetSessionWorkdir(context.Background(), sessionID, requested) - if err != nil { - return sessionWorkdirResultMsg{err: err} - } - workdir := strings.TrimSpace(session.Workdir) - if workdir == "" { - workdir = strings.TrimSpace(currentWorkdir) - } + result := tuicommands.ExecuteSessionWorkdirCommand( + runtime, + sessionID, + currentWorkdir, + raw, + parseWorkspaceSlashCommand, + tuiworkspace.ResolveWorkspacePath, + tuiworkspace.SelectSessionWorkdir, + ) return sessionWorkdirResultMsg{ - notice: fmt.Sprintf("[System] Session workspace switched to %s.", workdir), - workdir: workdir, - } - } -} - -func resolveWorkspacePath(base string, requested string) (string, error) { - base = strings.TrimSpace(base) - if base == "" { - workingDir, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("workspace: resolve current directory: %w", err) + Notice: result.Notice, + Workdir: result.Workdir, + Err: result.Err, } - base = workingDir } - - target := strings.TrimSpace(requested) - if target == "" { - target = "." - } - if !filepath.IsAbs(target) { - target = filepath.Join(base, target) - } - - absolute, err := filepath.Abs(target) - if err != nil { - return "", fmt.Errorf("workspace: resolve path: %w", err) - } - info, err := os.Stat(absolute) - if err != nil { - return "", fmt.Errorf("workspace: resolve path: %w", err) - } - if !info.IsDir() { - return "", fmt.Errorf("workspace: %q is not a directory", absolute) - } - return filepath.Clean(absolute), nil } -func selectSessionWorkdir(sessionWorkdir string, defaultWorkdir string) string { - workdir := strings.TrimSpace(sessionWorkdir) - if workdir != "" { - return workdir - } - return strings.TrimSpace(defaultWorkdir) -} - -// runCompact 在独立命令中触发 runtime compact,并把结果回传给 TUI。 +// runCompact 鍦ㄧ嫭绔嬪懡浠や腑瑙﹀彂 runtime compact锛屽苟鎶婄粨鏋滃洖浼犵粰 TUI銆 func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { - return func() tea.Msg { - _, err := runtime.Compact(context.Background(), agentruntime.CompactInput{SessionID: sessionID}) - return compactFinishedMsg{err: err} - } + return tuiservices.RunCompactCmd( + runtime, + agentruntime.CompactInput{SessionID: sessionID}, + func(err error) tea.Msg { return compactFinishedMsg{Err: err} }, + ) } -// isBusy 统一判断当前界面是否存在进行中的 agent 或 compact 操作。 +// isBusy 缁熶竴鍒ゆ柇褰撳墠鐣岄潰鏄惁瀛樺湪杩涜涓殑 agent 鎴?compact 鎿嶄綔銆 func (a App) isBusy() bool { - return a.state.IsAgentRunning || a.state.IsCompacting + return tuiutils.IsBusy(a.state.IsAgentRunning, a.state.IsCompacting) } diff --git a/internal/tui/view.go b/internal/tui/core/app/view.go similarity index 83% rename from internal/tui/view.go rename to internal/tui/core/app/view.go index 8398f4ef..10a47c10 100644 --- a/internal/tui/view.go +++ b/internal/tui/core/app/view.go @@ -8,6 +8,9 @@ import ( "github.com/charmbracelet/lipgloss" providertypes "neo-code/internal/provider/types" + tuicomponents "neo-code/internal/tui/components" + tuiutils "neo-code/internal/tui/core/utils" + tuistate "neo-code/internal/tui/state" ) type layout struct { @@ -21,6 +24,8 @@ type layout struct { bodyGap int } +const headerBarHeight = 1 + func (a App) View() string { docWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) docHeight := max(0, a.height-a.styles.doc.GetVerticalFrameSize()) @@ -44,15 +49,15 @@ func (a App) View() string { } func (a App) renderHeader(width int) string { - status := compactStatusText(a.state.StatusText, max(18, width/3)) + status := tuicomponents.CompactStatusText(a.state.StatusText, max(18, width/3)) if a.state.IsAgentRunning { if a.runProgressKnown { progressBar := a.progress - progressBar.Width = clamp(width/7, 12, 26) - progressLabel := fallback(strings.TrimSpace(a.runProgressLabel), fallback(status, statusRunning)) + progressBar.Width = tuiutils.Clamp(width/7, 12, 26) + progressLabel := tuiutils.Fallback(strings.TrimSpace(a.runProgressLabel), tuiutils.Fallback(status, statusRunning)) status = progressBar.ViewAs(a.runProgressValue) + " " + progressLabel } else { - status = a.spinner.View() + " " + fallback(status, statusRunning) + status = a.spinner.View() + " " + tuiutils.Fallback(status, statusRunning) } } @@ -76,7 +81,7 @@ func (a App) renderHeader(width int) string { lipgloss.Center, workdirLabel, " ", - a.styles.headerPath.Render(trimMiddle(a.state.CurrentWorkdir, workdirWidth)), + a.styles.headerPath.Render(tuiutils.TrimMiddle(a.state.CurrentWorkdir, workdirWidth)), ) header := lipgloss.JoinHorizontal( @@ -131,7 +136,7 @@ func (a App) renderWaterfall(width int, height int) string { height, lipgloss.Center, lipgloss.Center, - a.renderPicker(clamp(width-10, 36, 56), clamp(height-6, 10, 14)), + a.renderPicker(tuiutils.Clamp(width-10, 36, 56), tuiutils.Clamp(height-6, 10, 14)), ) } @@ -196,9 +201,9 @@ func (a App) renderSidebarHeader(width int) string { lipgloss.Center, title, lipgloss.NewStyle().Width(1).Render(""), - a.styles.panelSubtitle.Render(trimRunes(sidebarFilterHint, filterWidth)), + a.styles.panelSubtitle.Render(tuiutils.TrimRunes(sidebarFilterHint, filterWidth)), ) - openRow := a.styles.panelSubtitle.Render(trimRunes(sidebarOpenHint, width)) + openRow := a.styles.panelSubtitle.Render(tuiutils.TrimRunes(sidebarOpenHint, width)) return lipgloss.JoinVertical( lipgloss.Left, lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, titleRow), @@ -239,7 +244,7 @@ func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))), nil } - maxMessageWidth := clamp(int(float64(width)*0.84), 24, width) + maxMessageWidth := tuiutils.Clamp(int(float64(width)*0.84), 24, width) tag := messageTagAgent tagStyle := a.styles.messageAgentTag bodyStyle := a.styles.messageBody @@ -247,7 +252,7 @@ func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int switch message.Role { case roleUser: - maxMessageWidth = clamp(int(float64(width)*0.68), 24, width) + maxMessageWidth = tuiutils.Clamp(int(float64(width)*0.68), 24, width) tag = messageTagUser tagStyle = a.styles.messageUserTag bodyStyle = a.styles.messageUserBody @@ -300,13 +305,13 @@ func (a App) renderCommandMenu(width int) string { if body == "" { return "" } - return a.styles.commandMenu.Width(width).Render( - lipgloss.JoinVertical( - lipgloss.Left, - a.styles.commandMenuTitle.Render(title), - body, - ), - ) + return tuicomponents.RenderCommandMenu(tuicomponents.CommandMenuData{ + Title: title, + Body: body, + Width: width, + ContainerStyle: a.styles.commandMenu, + TitleStyle: a.styles.commandMenuTitle, + }) } func (a App) commandMenuHeight(width int) int { @@ -385,31 +390,11 @@ func (a App) renderMessageContentWithCopy(content string, width int, bodyStyle l } func normalizeBlockRightEdge(content string, maxWidth int) string { - if strings.TrimSpace(content) == "" { - return content - } - - lines := strings.Split(content, "\n") - targetWidth := 0 - for _, line := range lines { - targetWidth = max(targetWidth, lipgloss.Width(line)) - } - targetWidth = clamp(targetWidth, 1, maxWidth) - - padStyle := lipgloss.NewStyle().Width(targetWidth) - normalized := make([]string, 0, len(lines)) - for _, line := range lines { - normalized = append(normalized, padStyle.Render(line)) - } - return strings.Join(normalized, "\n") + return tuicomponents.NormalizeBlockRightEdge(content, maxWidth) } func trimRenderedTrailingWhitespace(content string) string { - lines := strings.Split(content, "\n") - for i := range lines { - lines[i] = strings.TrimRight(lines[i], " \t") - } - return strings.Join(lines, "\n") + return tuicomponents.TrimRenderedTrailingWhitespace(content) } func (a App) statusBadge(text string) string { @@ -427,41 +412,21 @@ func (a App) statusBadge(text string) string { } func compactStatusText(text string, limit int) string { - text = strings.ReplaceAll(text, "\r\n", "\n") - text = strings.ReplaceAll(text, "\r", "\n") - lines := strings.Split(text, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - line = strings.Join(strings.Fields(line), " ") - if limit > 0 { - return trimMiddle(line, limit) - } - return line - } - return "" + return tuicomponents.CompactStatusText(text, limit) } func (a App) focusLabel() string { - switch a.focus { - case panelSessions: - return focusLabelSessions - case panelTranscript: - return focusLabelTranscript - case panelActivity: - return focusLabelActivity - default: - return focusLabelComposer - } + return tuiutils.FocusLabelFromPanel( + a.focus, + focusLabelSessions, + focusLabelTranscript, + focusLabelActivity, + focusLabelComposer, + ) } func (a App) activityPreviewHeight() int { - if len(a.activities) == 0 { - return 0 - } - return 6 + return tuicomponents.ActivityPreviewHeight(len(a.activities)) } func (a App) renderActivityPreview(width int) string { @@ -480,28 +445,20 @@ func (a App) renderActivityPreview(width int) string { ) } -func (a App) renderActivityLine(entry activityEntry, width int) string { - timeLabel := entry.Time.Format("15:04:05") - kindLabel := strings.ToUpper(fallback(strings.TrimSpace(entry.Kind), "event")) - - text := entry.Title - if strings.TrimSpace(entry.Detail) != "" { - text = text + ": " + entry.Detail - } - - return trimMiddle(timeLabel+" "+kindLabel+" "+strings.Join(strings.Fields(text), " "), max(12, width)) +func (a App) renderActivityLine(entry tuistate.ActivityEntry, width int) string { + return tuicomponents.RenderActivityLine(entry, width) } func (a App) computeLayout() layout { contentWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) - headerHeight := lipgloss.Height(a.renderHeader(contentWidth)) - helpHeight := lipgloss.Height(a.renderHelp(contentWidth)) + helpHeight := a.helpHeight(contentWidth) + headerHeight := headerBarHeight contentHeight := max(1, a.height-a.styles.doc.GetVerticalFrameSize()-headerHeight-helpHeight) lay := layout{contentWidth: contentWidth, contentHeight: contentHeight} if contentWidth < 110 { lay.stacked = true lay.sidebarWidth = contentWidth - lay.sidebarHeight = clamp(contentHeight/3, 9, 13) + lay.sidebarHeight = tuiutils.Clamp(contentHeight/3, 9, 13) lay.rightWidth = contentWidth lay.rightHeight = max(10, contentHeight-lay.sidebarHeight) return lay @@ -515,6 +472,12 @@ func (a App) computeLayout() layout { return lay } +// helpHeight 仅计算帮助区高度,避免在 layout 计算阶段触发完整渲染。 +func (a App) helpHeight(width int) int { + a.help.ShowAll = a.state.ShowHelp + return lipgloss.Height(a.styles.footer.Width(width).Render(a.help.View(a.keys))) +} + func (a App) isFilteringSessions() bool { return a.sessions.FilterState() != list.Unfiltered } diff --git a/internal/tui/core/status/snapshot_test.go b/internal/tui/core/status/snapshot_test.go index 1b618833..abc3c60f 100644 --- a/internal/tui/core/status/snapshot_test.go +++ b/internal/tui/core/status/snapshot_test.go @@ -1,7 +1,6 @@ package status import ( - "strings" "testing" tuistate "neo-code/internal/tui/state" @@ -10,91 +9,180 @@ import ( func TestBuildFromUIState(t *testing.T) { state := tuistate.UIState{ ActiveSessionID: "session-1", - ActiveSessionTitle: "My Session", + ActiveSessionTitle: "Test Session", ActiveRunID: "run-1", IsAgentRunning: true, IsCompacting: false, CurrentProvider: "openai", - CurrentModel: "gpt-5.4", - CurrentWorkdir: "/repo", - CurrentTool: "filesystem_read_file", - ToolStates: []tuistate.ToolState{ - {ToolCallID: "call-1"}, - {ToolCallID: "call-2"}, - }, + CurrentModel: "gpt-4", + CurrentWorkdir: "/home/user", + CurrentTool: "bash", + ToolStates: []tuistate.ToolState{}, TokenUsage: tuistate.TokenUsageState{ - RunTotalTokens: 12, - SessionTotalTokens: 34, + RunTotalTokens: 100, + SessionTotalTokens: 500, }, - ExecutionError: "boom", + ExecutionError: "", } - snapshot := BuildFromUIState(state, 7, "transcript", "provider") - if snapshot.ActiveSessionID != "session-1" || snapshot.ActiveRunID != "run-1" { - t.Fatalf("unexpected snapshot identifiers: %+v", snapshot) + snapshot := BuildFromUIState(state, 10, "composer", "none") + + if snapshot.ActiveSessionID != "session-1" { + t.Errorf("expected session ID session-1, got %s", snapshot.ActiveSessionID) + } + if snapshot.ActiveSessionTitle != "Test Session" { + t.Errorf("expected session title 'Test Session', got %s", snapshot.ActiveSessionTitle) } - if snapshot.ToolStateCount != 2 || snapshot.RunTotalTokens != 12 || snapshot.SessionTotalTokens != 34 { - t.Fatalf("unexpected snapshot counters: %+v", snapshot) + if snapshot.IsAgentRunning != true { + t.Errorf("expected IsAgentRunning true, got %v", snapshot.IsAgentRunning) } - if snapshot.FocusLabel != "transcript" || snapshot.PickerLabel != "provider" || snapshot.MessageCount != 7 { - t.Fatalf("unexpected snapshot labels: %+v", snapshot) + if snapshot.CurrentProvider != "openai" { + t.Errorf("expected provider openai, got %s", snapshot.CurrentProvider) + } + if snapshot.MessageCount != 10 { + t.Errorf("expected message count 10, got %d", snapshot.MessageCount) + } + if snapshot.ToolStateCount != 0 { + t.Errorf("expected tool state count 0, got %d", snapshot.ToolStateCount) + } +} + +func TestBuildFromUIStateWithEmptyValues(t *testing.T) { + state := tuistate.UIState{ + ActiveSessionID: "", + ActiveSessionTitle: "", + ActiveRunID: "", + IsAgentRunning: false, + IsCompacting: false, + CurrentProvider: "", + CurrentModel: "", + CurrentWorkdir: "", + CurrentTool: "", + ToolStates: nil, + TokenUsage: tuistate.TokenUsageState{}, + ExecutionError: "", + } + + snapshot := BuildFromUIState(state, 0, "", "") + + if snapshot.ActiveSessionID != "" { + t.Errorf("expected empty session ID, got %s", snapshot.ActiveSessionID) + } + if snapshot.CurrentTool != "" { + t.Errorf("expected empty current tool, got %s", snapshot.CurrentTool) } } func TestFormat(t *testing.T) { - formatted := Format(Snapshot{ + snapshot := Snapshot{ + ActiveSessionID: "session-123", + ActiveSessionTitle: "My Session", + ActiveRunID: "run-456", + IsAgentRunning: true, + IsCompacting: false, + CurrentProvider: "openai", + CurrentModel: "gpt-4", + CurrentWorkdir: "/home/user", + CurrentTool: "bash", + ToolStateCount: 3, + RunTotalTokens: 150, + SessionTotalTokens: 1000, + ExecutionError: "", + FocusLabel: "composer", + PickerLabel: "none", + MessageCount: 5, + } + + output := Format(snapshot, "Draft Session") + + if !contains(output, "Session: My Session") { + t.Errorf("expected output to contain 'Session: My Session', got %s", output) + } + if !contains(output, "Running: yes") { + t.Errorf("expected output to contain 'Running: yes', got %s", output) + } + if !contains(output, "Provider: openai") { + t.Errorf("expected output to contain 'Provider: openai', got %s", output) + } + if !contains(output, "Current Tool: bash") { + t.Errorf("expected output to contain 'Current Tool: bash', got %s", output) + } +} + +func TestFormatWithEmptySession(t *testing.T) { + snapshot := Snapshot{ ActiveSessionID: "", ActiveSessionTitle: "", - ActiveRunID: " ", + ActiveRunID: "", IsAgentRunning: false, IsCompacting: false, CurrentProvider: "openai", - CurrentModel: "gpt-5.4", - CurrentWorkdir: "/repo", + CurrentModel: "gpt-4", + CurrentWorkdir: "/home/user", CurrentTool: "", - ToolStateCount: 1, - RunTotalTokens: 2, - SessionTotalTokens: 3, - ExecutionError: "", + ToolStateCount: 0, + RunTotalTokens: 0, + SessionTotalTokens: 0, + ExecutionError: "some error", FocusLabel: "composer", - PickerLabel: "", - MessageCount: 4, - }, "Draft Session") - - expectedParts := []string{ - "Session: Draft Session", - "Session ID: ", - "Run ID: ", - "Running: no", - "Picker: none", - "Current Tool: ", - "Error: ", - } - for _, part := range expectedParts { - if !strings.Contains(formatted, part) { - t.Fatalf("expected formatted status to contain %q, got:\n%s", part, formatted) - } + PickerLabel: "none", + MessageCount: 0, + } + + output := Format(snapshot, "Default Draft") + + if !contains(output, "Session: Default Draft") { + t.Errorf("expected output to contain 'Session: Default Draft', got %s", output) + } + if !contains(output, "Session ID: ") { + t.Errorf("expected output to contain 'Session ID: ', got %s", output) + } + if !contains(output, "Running: no") { + t.Errorf("expected output to contain 'Running: no', got %s", output) + } + if !contains(output, "Current Tool: ") { + t.Errorf("expected output to contain 'Current Tool: ', got %s", output) + } + if !contains(output, "Error: some error") { + t.Errorf("expected output to contain 'Error: some error', got %s", output) } +} - running := Format(Snapshot{ - ActiveSessionID: "session-2", - ActiveSessionTitle: "Named Session", - ActiveRunID: "run-2", +func TestFormatWithCompacting(t *testing.T) { + snapshot := Snapshot{ + ActiveSessionID: "session-1", + ActiveSessionTitle: "Test", + IsAgentRunning: false, IsCompacting: true, CurrentProvider: "openai", - CurrentModel: "gpt-5.4-mini", - CurrentWorkdir: "/repo", - CurrentTool: "tool-x", - ToolStateCount: 2, - RunTotalTokens: 10, - SessionTotalTokens: 20, - ExecutionError: "failed", - FocusLabel: "activity", - PickerLabel: "model", - MessageCount: 5, - }, "Ignored Draft") + CurrentModel: "gpt-4", + CurrentWorkdir: "/home", + CurrentTool: "", + ToolStateCount: 0, + RunTotalTokens: 0, + SessionTotalTokens: 0, + ExecutionError: "", + FocusLabel: "", + PickerLabel: "", + MessageCount: 0, + } - if !strings.Contains(running, "Session: Named Session") || !strings.Contains(running, "Running: yes") { - t.Fatalf("expected running status to keep explicit values, got:\n%s", running) + output := Format(snapshot, "") + + if !contains(output, "Running: yes") { + t.Errorf("expected output to contain 'Running: yes' when compacting, got %s", output) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr)) +} + +func containsAt(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } } + return false } diff --git a/internal/tui/core/utils/view_helpers_test.go b/internal/tui/core/utils/view_helpers_test.go index 5a342e06..9a37e084 100644 --- a/internal/tui/core/utils/view_helpers_test.go +++ b/internal/tui/core/utils/view_helpers_test.go @@ -7,91 +7,176 @@ import ( ) func TestPickerLabelFromMode(t *testing.T) { - if got := PickerLabelFromMode(tuistate.PickerProvider); got != "provider" { - t.Fatalf("expected provider label, got %q", got) + tests := []struct { + mode tuistate.PickerMode + want string + }{ + {tuistate.PickerProvider, "provider"}, + {tuistate.PickerModel, "model"}, + {tuistate.PickerFile, "file"}, + {tuistate.PickerMode(999), "none"}, } - if got := PickerLabelFromMode(tuistate.PickerModel); got != "model" { - t.Fatalf("expected model label, got %q", got) - } - if got := PickerLabelFromMode(tuistate.PickerFile); got != "file" { - t.Fatalf("expected file label, got %q", got) - } - if got := PickerLabelFromMode(tuistate.PickerMode(99)); got != "none" { - t.Fatalf("expected default picker label none, got %q", got) + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := PickerLabelFromMode(tt.mode); got != tt.want { + t.Errorf("PickerLabelFromMode(%v) = %v, want %v", tt.mode, got, tt.want) + } + }) } } func TestRequestedWorkdirForRun(t *testing.T) { - if got := RequestedWorkdirForRun("", "/repo"); got != "/repo" { - t.Fatalf("expected current workdir when active session is blank, got %q", got) + tests := []struct { + name string + activeSessionID string + currentWorkdir string + want string + }{ + {"empty session returns current", "", "/home/user", "/home/user"}, + {"active session returns empty", "session-1", "/home/user", ""}, } - if got := RequestedWorkdirForRun("session-1", "/repo"); got != "" { - t.Fatalf("expected empty requested workdir when active session exists, got %q", got) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RequestedWorkdirForRun(tt.activeSessionID, tt.currentWorkdir); got != tt.want { + t.Errorf("RequestedWorkdirForRun() = %v, want %v", got, tt.want) + } + }) } } func TestIsBusy(t *testing.T) { - if IsBusy(false, false) { - t.Fatalf("expected idle state") + tests := []struct { + name string + isAgentRunning bool + isCompacting bool + want bool + }{ + {"both false", false, false, false}, + {"agent running", true, false, true}, + {"compacting", false, true, true}, + {"both true", true, true, true}, } - if !IsBusy(true, false) || !IsBusy(false, true) || !IsBusy(true, true) { - t.Fatalf("expected busy state when any operation is running") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsBusy(tt.isAgentRunning, tt.isCompacting); got != tt.want { + t.Errorf("IsBusy() = %v, want %v", got, tt.want) + } + }) } } func TestFocusLabelFromPanel(t *testing.T) { - const ( - sessions = "Sessions" - transcript = "Transcript" - activity = "Activity" - composer = "Composer" - ) - - if got := FocusLabelFromPanel(tuistate.PanelSessions, sessions, transcript, activity, composer); got != sessions { - t.Fatalf("expected sessions label, got %q", got) - } - if got := FocusLabelFromPanel(tuistate.PanelTranscript, sessions, transcript, activity, composer); got != transcript { - t.Fatalf("expected transcript label, got %q", got) - } - if got := FocusLabelFromPanel(tuistate.PanelActivity, sessions, transcript, activity, composer); got != activity { - t.Fatalf("expected activity label, got %q", got) + tests := []struct { + name string + focus tuistate.Panel + want string + }{ + {"sessions", tuistate.PanelSessions, "sessions"}, + {"transcript", tuistate.PanelTranscript, "transcript"}, + {"activity", tuistate.PanelActivity, "activity"}, + {"input falls to default", tuistate.PanelInput, "composer"}, + {"unknown", tuistate.Panel(999), "composer"}, } - if got := FocusLabelFromPanel(tuistate.PanelInput, sessions, transcript, activity, composer); got != composer { - t.Fatalf("expected composer label, got %q", got) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FocusLabelFromPanel(tt.focus, "sessions", "transcript", "activity", "composer"); got != tt.want { + t.Errorf("FocusLabelFromPanel() = %v, want %v", got, tt.want) + } + }) } } -func TestTrimHelpers(t *testing.T) { - if got := TrimRunes("abcdef", 3); got != "abcdef" { - t.Fatalf("expected original text when limit < 4, got %q", got) +func TestTrimRunes(t *testing.T) { + tests := []struct { + name string + text string + limit int + want string + }{ + {"short text", "hello", 10, "hello"}, + {"exact limit", "hello", 5, "hello"}, + {"long text", "hello world", 8, "hello..."}, + {"limit too small", "hello", 2, "hello"}, + {"limit 3", "hello", 4, "h..."}, } - if got := TrimRunes("abcdef", 5); got != "ab..." { - t.Fatalf("expected rune-safe truncation, got %q", got) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TrimRunes(tt.text, tt.limit); got != tt.want { + t.Errorf("TrimRunes(%q, %d) = %q, want %q", tt.text, tt.limit, got, tt.want) + } + }) } +} - if got := TrimMiddle("abcdef", 6); got != "abcdef" { - t.Fatalf("expected no trim when limit < 7, got %q", got) +func TestTrimMiddle(t *testing.T) { + tests := []struct { + name string + text string + limit int + want string + }{ + {"short text", "hello", 10, "hello"}, + {"exact limit", "hello", 5, "hello"}, + {"long text", "abcdefghij", 8, "ab...hij"}, + {"limit too small", "hello", 5, "hello"}, + {"limit 6", "abcdefghij", 7, "ab...ij"}, } - if got := TrimMiddle("abcdefghij", 7); got != "ab...ij" { - t.Fatalf("expected middle trim output, got %q", got) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TrimMiddle(tt.text, tt.limit); got != tt.want { + t.Errorf("TrimMiddle(%q, %d) = %q, want %q", tt.text, tt.limit, got, tt.want) + } + }) } } -func TestFallbackAndClamp(t *testing.T) { - if got := Fallback("value", "fallback"); got != "value" { - t.Fatalf("expected value when non-empty, got %q", got) - } - if got := Fallback(" ", "fallback"); got != "fallback" { - t.Fatalf("expected fallback for blank value, got %q", got) +func TestFallback(t *testing.T) { + tests := []struct { + name string + value string + fallbackValue string + want string + }{ + {"empty value", "", "default", "default"}, + {"whitespace only", " ", "default", "default"}, + {"normal value", "actual", "default", "actual"}, } - if got := Clamp(-1, 0, 10); got != 0 { - t.Fatalf("expected clamp to min, got %d", got) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Fallback(tt.value, tt.fallbackValue); got != tt.want { + t.Errorf("Fallback(%q, %q) = %q, want %q", tt.value, tt.fallbackValue, got, tt.want) + } + }) } - if got := Clamp(11, 0, 10); got != 10 { - t.Fatalf("expected clamp to max, got %d", got) +} + +func TestClamp(t *testing.T) { + tests := []struct { + name string + value int + minValue int + maxValue int + want int + }{ + {"within range", 5, 0, 10, 5}, + {"below min", -1, 0, 10, 0}, + {"above max", 15, 0, 10, 10}, + {"at min", 0, 0, 10, 0}, + {"at max", 10, 0, 10, 10}, } - if got := Clamp(5, 0, 10); got != 5 { - t.Fatalf("expected in-range value unchanged, got %d", got) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Clamp(tt.value, tt.minValue, tt.maxValue); got != tt.want { + t.Errorf("Clamp(%d, %d, %d) = %d, want %d", tt.value, tt.minValue, tt.maxValue, got, tt.want) + } + }) } } diff --git a/internal/tui/core/workspace/resolver_test.go b/internal/tui/core/workspace/resolver_test.go index 790150b3..0d1d9621 100644 --- a/internal/tui/core/workspace/resolver_test.go +++ b/internal/tui/core/workspace/resolver_test.go @@ -3,65 +3,82 @@ package workspace import ( "os" "path/filepath" - "strings" "testing" ) func TestResolveWorkspacePath(t *testing.T) { base := t.TempDir() - childDir := filepath.Join(base, "project") - if err := os.MkdirAll(childDir, 0o755); err != nil { - t.Fatalf("mkdir child dir: %v", err) + subdir := filepath.Join(base, "sub") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) } - resolved, err := ResolveWorkspacePath(base, "project") - if err != nil { - t.Fatalf("ResolveWorkspacePath(relative) error = %v", err) - } - if resolved != filepath.Clean(childDir) { - t.Fatalf("unexpected resolved path: %q", resolved) - } - - resolved, err = ResolveWorkspacePath(base, "") - if err != nil { - t.Fatalf("ResolveWorkspacePath(default current) error = %v", err) - } - if resolved != filepath.Clean(base) { - t.Fatalf("expected base directory for empty requested path, got %q", resolved) - } - - // Empty base falls back to os.Getwd(). - resolved, err = ResolveWorkspacePath("", ".") - if err != nil { - t.Fatalf("ResolveWorkspacePath(empty base) error = %v", err) - } - cwd, _ := os.Getwd() - if resolved != filepath.Clean(cwd) { - t.Fatalf("expected current directory for empty base, got %q", resolved) - } -} - -func TestResolveWorkspacePathErrors(t *testing.T) { - base := t.TempDir() - filePath := filepath.Join(base, "not-dir.txt") - if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { - t.Fatalf("write file: %v", err) + tests := []struct { + name string + base string + requested string + check func(t *testing.T, got string) + wantErr bool + }{ + {"resolve absolute path", base, subdir, func(t *testing.T, got string) { + if got != subdir { + t.Errorf("expected %v, got %v", subdir, got) + } + }, false}, + {"resolve relative path", base, "sub", func(t *testing.T, got string) { + if got != subdir { + t.Errorf("expected %v, got %v", subdir, got) + } + }, false}, + {"empty base uses cwd", "", ".", func(t *testing.T, got string) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if got != cwd { + t.Errorf("expected %v, got %v", cwd, got) + } + }, false}, + {"empty requested uses dot", base, "", func(t *testing.T, got string) { + if got != base { + t.Errorf("expected %v, got %v", base, got) + } + }, false}, + {"non-existent path", base, "nonexistent", func(t *testing.T, got string) {}, true}, } - if _, err := ResolveWorkspacePath(base, "missing-dir"); err == nil { - t.Fatalf("expected missing path to return error") - } - - if _, err := ResolveWorkspacePath(base, "not-dir.txt"); err == nil || !strings.Contains(err.Error(), "not a directory") { - t.Fatalf("expected non-directory path error, got %v", err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveWorkspacePath(tt.base, tt.requested) + if (err != nil) != tt.wantErr { + t.Errorf("ResolveWorkspacePath() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && tt.check != nil { + tt.check(t, got) + } + }) } } func TestSelectSessionWorkdir(t *testing.T) { - if got := SelectSessionWorkdir(" /session ", "/default"); got != "/session" { - t.Fatalf("expected session workdir priority, got %q", got) + tests := []struct { + name string + sessionWorkdir string + defaultWorkdir string + want string + }{ + {"prefer session workdir", "/session", "/default", "/session"}, + {"fallback to default", "", "/default", "/default"}, + {"both empty", "", "", ""}, + {"session with whitespace", " /session ", "/default", "/session"}, } - if got := SelectSessionWorkdir(" ", " /default "); got != "/default" { - t.Fatalf("expected default workdir fallback, got %q", got) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SelectSessionWorkdir(tt.sessionWorkdir, tt.defaultWorkdir); got != tt.want { + t.Errorf("SelectSessionWorkdir() = %v, want %v", got, tt.want) + } + }) } } diff --git a/internal/tui/input_features_test.go b/internal/tui/input_features_test.go deleted file mode 100644 index ade4fb7f..00000000 --- a/internal/tui/input_features_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package tui - -import ( - "context" - "os" - "path/filepath" - goruntime "runtime" - "strings" - "testing" - - "neo-code/internal/config" -) - -func TestWorkspaceCommandHelpers(t *testing.T) { - t.Run("execute workspace command forwards explicit workdir", func(t *testing.T) { - manager := newTestConfigManager(t) - capturedWorkdir := "" - capturedCommand := "" - previous := workspaceCommandExecutor - t.Cleanup(func() { workspaceCommandExecutor = previous }) - workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { - capturedWorkdir = workdir - capturedCommand = command - return "ok", nil - } - - target := t.TempDir() - command, output, err := executeWorkspaceCommand(context.Background(), manager, target, "& git status") - if err != nil { - t.Fatalf("executeWorkspaceCommand() error = %v", err) - } - if command != "git status" || output != "ok" { - t.Fatalf("unexpected execute result command=%q output=%q", command, output) - } - if capturedWorkdir != target || capturedCommand != "git status" { - t.Fatalf("expected forwarded workdir=%q command=%q, got workdir=%q command=%q", target, "git status", capturedWorkdir, capturedCommand) - } - - msg := runWorkspaceCommand(manager, target, "& git status")() - result, ok := msg.(workspaceCommandResultMsg) - if !ok { - t.Fatalf("expected workspaceCommandResultMsg, got %T", msg) - } - if result.err != nil || result.command != "git status" || result.output != "ok" { - t.Fatalf("unexpected runWorkspaceCommand result: %+v", result) - } - }) - - t.Run("extract workspace command validates prefix and body", func(t *testing.T) { - if _, err := extractWorkspaceCommand("git status"); err == nil { - t.Fatalf("expected missing prefix to fail") - } - if _, err := extractWorkspaceCommand("& "); err == nil { - t.Fatalf("expected empty command to fail") - } - got, err := extractWorkspaceCommand(" & git status ") - if err != nil || got != "git status" { - t.Fatalf("expected git status, got %q / %v", got, err) - } - }) - - t.Run("shell args support bash sh and default powershell", func(t *testing.T) { - if got := shellArgs("bash", "pwd"); len(got) != 3 || got[0] != "bash" || got[2] != "pwd" { - t.Fatalf("unexpected bash args %+v", got) - } - if got := shellArgs("sh", "pwd"); len(got) != 3 || got[0] != "sh" || got[2] != "pwd" { - t.Fatalf("unexpected sh args %+v", got) - } - if got := shellArgs("unknown", "git status"); len(got) != 4 || got[0] != "powershell" { - t.Fatalf("expected fallback to powershell, got %+v", got) - } - }) - - t.Run("format workspace command result handles failures and escapes code fences", func(t *testing.T) { - got := formatWorkspaceCommandResult("git status", "before ``` after", context.DeadlineExceeded) - for _, want := range []string{"Command Failed: & git status", "` ` `", "before"} { - if !strings.Contains(got, want) { - t.Fatalf("expected formatted result to contain %q, got %q", want, got) - } - } - }) - - t.Run("default workspace command executor rejects empty commands", func(t *testing.T) { - output, err := defaultWorkspaceCommandExecutor(context.Background(), config.Config{}, "", " ") - if err == nil || !strings.Contains(err.Error(), "empty") || output != "" { - t.Fatalf("expected empty command error, got output=%q err=%v", output, err) - } - }) - - t.Run("default workspace command executor falls back to cfg workdir", func(t *testing.T) { - workdir := t.TempDir() - cfg := config.Config{ - Workdir: workdir, - ToolTimeoutSec: 15, - } - command := "pwd" - if goruntime.GOOS == "windows" { - cfg.Shell = "powershell" - command = "$PWD.Path" - } else { - cfg.Shell = "sh" - } - - output, err := defaultWorkspaceCommandExecutor(context.Background(), cfg, "", command) - if err != nil { - t.Fatalf("defaultWorkspaceCommandExecutor() error = %v", err) - } - normalizedOutput := strings.ToLower(filepath.Clean(strings.TrimSpace(output))) - normalizedWorkdir := strings.ToLower(filepath.Clean(workdir)) - if !strings.Contains(normalizedOutput, normalizedWorkdir) { - t.Fatalf("expected output %q to contain resolved workdir %q", output, workdir) - } - }) -} - -func TestWorkspaceFileHelpers(t *testing.T) { - root := t.TempDir() - mustWrite := func(rel string) { - t.Helper() - path := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", rel, err) - } - if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { - t.Fatalf("write %s: %v", rel, err) - } - } - - mustWrite("README.md") - mustWrite("internal/tui/update.go") - mustWrite("internal/tui/view.go") - mustWrite("node_modules/skip.js") - mustWrite(".git/config") - - t.Run("collect workspace files skips ignored directories and respects limit", func(t *testing.T) { - files, err := collectWorkspaceFiles(root, 2) - if err != nil { - t.Fatalf("collectWorkspaceFiles() error = %v", err) - } - if len(files) != 2 { - t.Fatalf("expected limited result size 2, got %d (%v)", len(files), files) - } - - files, err = collectWorkspaceFiles(root, 10) - if err != nil { - t.Fatalf("collectWorkspaceFiles() error = %v", err) - } - got := strings.Join(files, ",") - if strings.Contains(got, "node_modules") || strings.Contains(got, ".git") { - t.Fatalf("expected ignored directories to be skipped, got %v", files) - } - if !strings.Contains(got, "internal/tui/update.go") || !strings.Contains(got, "internal/tui/view.go") { - t.Fatalf("expected workspace files to be collected, got %v", files) - } - }) - - t.Run("current reference token detects token boundaries", func(t *testing.T) { - if _, _, _, ok := currentReferenceToken("hello world"); ok { - t.Fatalf("expected non-reference token to be ignored") - } - start, end, token, ok := currentReferenceToken("inspect @internal/tui/upd") - if !ok || token != "@internal/tui/upd" || start >= end { - t.Fatalf("unexpected token result start=%d end=%d token=%q ok=%v", start, end, token, ok) - } - }) - - t.Run("matching references and apply suggestion handle both hit and miss cases", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.fileCandidates = []string{"README.md", "internal/tui/update.go", "internal/tui/view.go"} - app.input.SetValue("inspect @internal/tui/upd") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - - _, _, _, suggestions, ok := app.resolveFileReferenceSuggestions(app.input.Value()) - if !ok || len(suggestions) == 0 || suggestions[0] != "internal/tui/update.go" { - t.Fatalf("unexpected suggestions %v", suggestions) - } - if !app.applySelectedCommandSuggestion() { - t.Fatalf("expected top suggestion to be applied") - } - if app.state.InputText != "inspect @internal/tui/update.go" { - t.Fatalf("unexpected completed input %q", app.state.InputText) - } - - app.input.SetValue("inspect plain-text") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - if app.applySelectedCommandSuggestion() { - t.Fatalf("expected applySelectedCommandSuggestion to fail without @ token") - } - }) - - t.Run("apply file reference handles replace append and path resolution", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - workdir := t.TempDir() - inside := filepath.Join(workdir, "internal", "tui", "update.go") - outside := filepath.Join(t.TempDir(), "outside.go") - app.state.CurrentWorkdir = workdir - - app.input.SetValue("inspect @old/path.go") - app.state.InputText = app.input.Value() - if err := app.applyFileReference(inside); err != nil { - t.Fatalf("applyFileReference(replace) error = %v", err) - } - if !strings.Contains(app.state.InputText, "@internal/tui/update.go") { - t.Fatalf("expected replaced relative reference, got %q", app.state.InputText) - } - - app.input.SetValue("inspect") - app.state.InputText = app.input.Value() - if err := app.applyFileReference(inside); err != nil { - t.Fatalf("applyFileReference(append with space) error = %v", err) - } - if !strings.HasPrefix(app.state.InputText, "inspect @internal/tui/update.go") { - t.Fatalf("expected appended reference with separator, got %q", app.state.InputText) - } - - app.input.SetValue("inspect ") - app.state.InputText = app.input.Value() - if err := app.applyFileReference(inside); err != nil { - t.Fatalf("applyFileReference(append without extra space) error = %v", err) - } - if strings.Contains(app.state.InputText, "inspect @") { - t.Fatalf("expected single separator before reference, got %q", app.state.InputText) - } - - app.input.SetValue(" ") - app.state.InputText = app.input.Value() - if err := app.applyFileReference(outside); err != nil { - t.Fatalf("applyFileReference(empty input) error = %v", err) - } - outsideAbs, _ := filepath.Abs(outside) - if !strings.Contains(app.state.InputText, "@"+filepath.ToSlash(outsideAbs)) { - t.Fatalf("expected absolute reference for outside file, got %q", app.state.InputText) - } - - if err := app.applyFileReference(" "); err == nil { - t.Fatalf("expected empty path to fail") - } - }) -} diff --git a/internal/tui/markdown_renderer.go b/internal/tui/markdown_renderer.go deleted file mode 100644 index 17fcbeaf..00000000 --- a/internal/tui/markdown_renderer.go +++ /dev/null @@ -1,100 +0,0 @@ -package tui - -import ( - "fmt" - "regexp" - "strings" - - "github.com/charmbracelet/glamour" -) - -const ( - defaultMarkdownStyle = "dark" - defaultMarkdownCacheMax = 128 -) - -var markdownANSIPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -type markdownContentRenderer interface { - Render(content string, width int) (string, error) -} - -type glamourMarkdownRenderer struct { - renderers map[int]*glamour.TermRenderer - cache map[string]string - cacheOrder []string - maxCacheEntries int -} - -func newMarkdownRenderer() (markdownContentRenderer, error) { - return &glamourMarkdownRenderer{ - renderers: make(map[int]*glamour.TermRenderer), - cache: make(map[string]string), - cacheOrder: make([]string, 0, defaultMarkdownCacheMax), - maxCacheEntries: defaultMarkdownCacheMax, - }, nil -} - -func (r *glamourMarkdownRenderer) Render(content string, width int) (string, error) { - if strings.TrimSpace(content) == "" { - return emptyMessageText, nil - } - - renderWidth := max(16, width) - cacheKey := fmt.Sprintf("%d:%s", renderWidth, content) - if cached, ok := r.cache[cacheKey]; ok { - return cached, nil - } - - termRenderer, err := r.rendererForWidth(renderWidth) - if err != nil { - return "", err - } - - rendered, err := termRenderer.Render(content) - if err != nil { - return "", err - } - rendered = strings.TrimRight(rendered, "\n") - visible := markdownANSIPattern.ReplaceAllString(rendered, "") - if strings.TrimSpace(visible) == "" { - rendered = emptyMessageText - } - - r.cacheResult(cacheKey, rendered) - return rendered, nil -} - -func (r *glamourMarkdownRenderer) rendererForWidth(width int) (*glamour.TermRenderer, error) { - if renderer, ok := r.renderers[width]; ok { - return renderer, nil - } - - renderer, err := glamour.NewTermRenderer( - glamour.WithStandardStyle(defaultMarkdownStyle), - glamour.WithWordWrap(width), - ) - if err != nil { - return nil, err - } - - r.renderers[width] = renderer - return renderer, nil -} - -func (r *glamourMarkdownRenderer) cacheResult(key string, value string) { - if r.maxCacheEntries <= 0 { - return - } - if _, exists := r.cache[key]; exists { - r.cache[key] = value - return - } - if len(r.cacheOrder) >= r.maxCacheEntries { - oldest := r.cacheOrder[0] - r.cacheOrder = r.cacheOrder[1:] - delete(r.cache, oldest) - } - r.cacheOrder = append(r.cacheOrder, key) - r.cache[key] = value -} diff --git a/internal/tui/provider_service.go b/internal/tui/provider_service.go deleted file mode 100644 index 8893c139..00000000 --- a/internal/tui/provider_service.go +++ /dev/null @@ -1,15 +0,0 @@ -package tui - -import ( - "context" - - "neo-code/internal/config" -) - -type ProviderController interface { - ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) - SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) - ListModels(ctx context.Context) ([]config.ModelDescriptor, error) - ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) - SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) -} diff --git a/internal/tui/state.go b/internal/tui/state.go deleted file mode 100644 index c65ecc63..00000000 --- a/internal/tui/state.go +++ /dev/null @@ -1,223 +0,0 @@ -package tui - -import ( - "fmt" - "io" - "strings" - "time" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - agentsession "neo-code/internal/session" -) - -type panel int - -const ( - panelSessions panel = iota - panelTranscript - panelActivity - panelInput -) - -type pickerMode int - -const ( - pickerNone pickerMode = iota - pickerProvider - pickerModel - pickerFile -) - -type UIState struct { - Sessions []agentsession.Summary - ActiveSessionID string - ActiveSessionTitle string - InputText string - IsAgentRunning bool - IsCompacting bool - StreamingReply bool - CurrentTool string - ExecutionError string - StatusText string - CurrentProvider string - CurrentModel string - CurrentWorkdir string - ShowHelp bool - ActivePicker pickerMode - Focus panel -} - -type activityEntry struct { - Time time.Time - Kind string - Title string - Detail string - IsError bool -} - -type pendingPermissionPrompt struct { - RequestID string - ToolCallID string - ToolName string - ToolCategory string - Target string - Submitted bool -} - -type commandMenuMeta struct { - Title string -} - -type commandMenuItem struct { - title string - description string - filter string - highlight bool - replacement string - useReplaceRange bool - replaceStart int - replaceEnd int - openFileBrowser bool -} - -func (c commandMenuItem) Title() string { - return c.title -} - -func (c commandMenuItem) Description() string { - return c.description -} - -func (c commandMenuItem) FilterValue() string { - base := strings.TrimSpace(c.filter) - if base != "" { - return strings.ToLower(base) - } - return strings.ToLower(c.title + " " + c.description) -} - -type commandMenuDelegate struct { - styles styles -} - -func (d commandMenuDelegate) Height() int { - return 1 -} - -func (d commandMenuDelegate) Spacing() int { - return 0 -} - -func (d commandMenuDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - return nil -} - -func (d commandMenuDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - entry, ok := item.(commandMenuItem) - if !ok { - return - } - - contentWidth := max(12, m.Width()-2) - usageStyle := d.styles.commandUsage - if entry.highlight || index == m.Index() { - usageStyle = d.styles.commandUsageMatch - } - - line := usageStyle.Render(entry.title) - if description := strings.TrimSpace(entry.description); description != "" { - descWidth := max(8, contentWidth-lipgloss.Width(entry.title)-2) - line = lipgloss.JoinHorizontal( - lipgloss.Top, - line, - lipgloss.NewStyle().Width(2).Render(""), - d.styles.commandDesc.Render(trimMiddle(description, descWidth)), - ) - } - - fmt.Fprint(w, lipgloss.NewStyle().Width(contentWidth).Render(line)) -} - -type sessionItem struct { - Summary agentsession.Summary - Active bool -} - -func (s sessionItem) FilterValue() string { - return strings.ToLower(s.Summary.Title) -} - -type selectionItem struct { - id string - name string - description string -} - -func (s selectionItem) Title() string { - return s.name -} - -func (s selectionItem) Description() string { - return s.description -} - -func (s selectionItem) FilterValue() string { - return strings.ToLower(s.id + " " + s.name + " " + s.description) -} - -type sessionDelegate struct { - styles styles -} - -func (d sessionDelegate) Height() int { - return 3 -} - -func (d sessionDelegate) Spacing() int { - return 1 -} - -func (d sessionDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - return nil -} - -func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - session, ok := item.(sessionItem) - if !ok { - return - } - - width := max(18, m.Width()-2) - title := trimRunes(session.Summary.Title, max(8, width-10)) - meta := session.Summary.UpdatedAt.Format("01-02 15:04") - - prefix := "o" - if session.Active { - prefix = "*" - } - if index == m.Index() { - prefix = ">" - } - - style := d.styles.sessionRow - metaStyle := d.styles.sessionMeta - if session.Active { - style = d.styles.sessionRowActive - metaStyle = d.styles.sessionMetaActive - } - if index == m.Index() { - style = d.styles.sessionRowFocused - metaStyle = d.styles.sessionMetaFocus - } - - content := lipgloss.JoinVertical( - lipgloss.Left, - fmt.Sprintf("%s %s", prefix, title), - metaStyle.Render(" "+meta), - ) - - fmt.Fprint(w, style.Width(width).Render(content)) -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 00000000..64690b5e --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,21 @@ +package tui + +import ( + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" + tuibootstrap "neo-code/internal/tui/bootstrap" + tuiapp "neo-code/internal/tui/core/app" +) + +type App = tuiapp.App +type ProviderController = tuiapp.ProviderController + +// New 保留 internal/tui 对外入口,内部实现转发到分层后的 core/app。 +func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime, providerSvc ProviderController) (App, error) { + return tuiapp.New(cfg, configManager, runtime, providerSvc) +} + +// NewWithBootstrap 保留对外注入入口,内部转发到 core/app。 +func NewWithBootstrap(options tuibootstrap.Options) (App, error) { + return tuiapp.NewWithBootstrap(options) +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 00000000..f5e7e6c8 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,35 @@ +package tui + +import ( + "testing" + + "neo-code/internal/config" + "neo-code/internal/runtime" + tuibootstrap "neo-code/internal/tui/bootstrap" +) + +func TestAppTypeAlias(t *testing.T) { + var _ App = App{} +} + +func TestProviderControllerTypeAlias(t *testing.T) { + var _ ProviderController = ProviderController(nil) +} + +func TestNewForwardsToCore(t *testing.T) { + t.Run("nil config", func(t *testing.T) { + _, err := New(nil, &config.Manager{}, &runtime.Service{}, nil) + if err == nil { + t.Error("expected error for nil runtime") + } + }) +} + +func TestNewWithBootstrapForwardsToCore(t *testing.T) { + t.Run("empty options", func(t *testing.T) { + _, err := NewWithBootstrap(tuibootstrap.Options{}) + if err == nil { + t.Error("expected error for empty options") + } + }) +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go deleted file mode 100644 index c04a5e73..00000000 --- a/internal/tui/update_test.go +++ /dev/null @@ -1,2816 +0,0 @@ -package tui - -import ( - "bytes" - "context" - "errors" - "os" - "path/filepath" - "regexp" - goruntime "runtime" - "strings" - "sync" - "testing" - "time" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "neo-code/internal/config" - contextcompact "neo-code/internal/context/compact" - "neo-code/internal/provider" - providercatalog "neo-code/internal/provider/catalog" - providertypes "neo-code/internal/provider/types" - agentruntime "neo-code/internal/runtime" - agentsession "neo-code/internal/session" - "neo-code/internal/tools" -) - -type stubRuntime struct { - runInputs []agentruntime.UserInput - compactInputs []agentruntime.CompactInput - events chan agentruntime.RuntimeEvent - sessions []agentsession.Summary - loads map[string]agentsession.Session - runErr error - compactErr error - compactResult agentruntime.CompactResult - listErr error - loadErr error - setWorkdirErr error - setResult *agentsession.Session - setCalls int - resolveInputs []agentruntime.PermissionResolutionInput - resolveErr error - cancelCalls int - cancelResult bool -} - -type stubMarkdownRenderer struct { - output string - err error - calls int -} - -var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -func (r *stubMarkdownRenderer) Render(content string, width int) (string, error) { - r.calls++ - if r.err != nil { - return "", r.err - } - if r.output != "" { - return r.output, nil - } - return content, nil -} - -func newStubRuntime() *stubRuntime { - return &stubRuntime{ - events: make(chan agentruntime.RuntimeEvent, 16), - loads: map[string]agentsession.Session{}, - } -} - -func (r *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { - r.runInputs = append(r.runInputs, input) - return r.runErr -} - -func (r *stubRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { - r.compactInputs = append(r.compactInputs, input) - return r.compactResult, r.compactErr -} - -func (r *stubRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { - r.resolveInputs = append(r.resolveInputs, input) - return r.resolveErr -} - -func (r *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { - return r.events -} - -func (r *stubRuntime) CancelActiveRun() bool { - r.cancelCalls++ - return r.cancelResult -} - -func (r *stubRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { - if r.listErr != nil { - return nil, r.listErr - } - return append([]agentsession.Summary(nil), r.sessions...), nil -} - -func (r *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { - if r.loadErr != nil { - return agentsession.Session{}, r.loadErr - } - if session, ok := r.loads[id]; ok { - return session, nil - } - return agentsession.Session{}, nil -} - -func (r *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - r.setCalls++ - if r.setWorkdirErr != nil { - return agentsession.Session{}, r.setWorkdirErr - } - if r.setResult != nil { - return *r.setResult, nil - } - session, ok := r.loads[sessionID] - if !ok { - session = agentsession.Session{ID: sessionID} - } - session.Workdir = strings.TrimSpace(workdir) - r.loads[sessionID] = session - return session, nil -} - -func TestAppUpdateComposerCommands(t *testing.T) { - tests := []struct { - name string - input string - assert func(t *testing.T, beforeRuntime *stubRuntime, manager *config.Manager, app App) - }{ - { - name: "unknown slash command stays local and surfaces error", - input: "/unknown", - assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { - t.Helper() - if len(runtime.runInputs) != 0 { - t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) - } - if !strings.Contains(app.state.StatusText, "unknown command") { - t.Fatalf("expected unknown command error, got %q", app.state.StatusText) - } - if app.state.IsAgentRunning { - t.Fatalf("expected agent to stay idle") - } - }, - }, - { - name: "provider command opens picker and does not start runtime", - input: "/provider\n", - assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { - t.Helper() - if len(runtime.runInputs) != 0 { - t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) - } - if app.state.ActivePicker != pickerProvider { - t.Fatalf("expected provider picker to open") - } - if app.state.StatusText != statusChooseProvider { - t.Fatalf("expected status %q, got %q", statusChooseProvider, app.state.StatusText) - } - }, - }, - { - name: "model command opens picker and does not start runtime", - input: "/model", - assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { - t.Helper() - if len(runtime.runInputs) != 0 { - t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) - } - if app.state.ActivePicker != pickerModel { - t.Fatalf("expected model picker to open") - } - if app.state.StatusText != statusChooseModel { - t.Fatalf("expected status %q, got %q", statusChooseModel, app.state.StatusText) - } - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.input.SetValue(tt.input) - app.state.InputText = tt.input - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - - for _, msg := range collectTeaMessages(cmd) { - model, followCmd := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(followCmd) - } - - tt.assert(t, runtime, manager, app) - }) - } -} - -func TestAppUpdateWorkspaceSlashCommands(t *testing.T) { - t.Run("draft workspace command updates local current workdir", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - target := t.TempDir() - app.input.SetValue("/cwd " + target) - app.state.InputText = app.input.Value() - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - for _, msg := range collectTeaMessages(cmd) { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected slash command not to call runtime.Run, got %+v", runtime.runInputs) - } - if runtime.setCalls != 0 { - t.Fatalf("expected draft workspace change not to call SetSessionWorkdir") - } - if app.state.CurrentWorkdir != target { - t.Fatalf("expected current workdir %q, got %q", target, app.state.CurrentWorkdir) - } - if !strings.Contains(app.state.StatusText, "Draft workspace switched") { - t.Fatalf("expected draft workspace switch status, got %q", app.state.StatusText) - } - }) - - t.Run("session workspace command updates runtime session workdir", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - sessionID := "session-workdir" - runtime.loads[sessionID] = agentsession.Session{ID: sessionID, Workdir: t.TempDir()} - - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.state.ActiveSessionID = sessionID - target := t.TempDir() - - app.input.SetValue("/cwd " + target) - app.state.InputText = app.input.Value() - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - for _, msg := range collectTeaMessages(cmd) { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - - if runtime.setCalls != 1 { - t.Fatalf("expected SetSessionWorkdir to be called once, got %d", runtime.setCalls) - } - if app.state.CurrentWorkdir != target { - t.Fatalf("expected current workdir %q, got %q", target, app.state.CurrentWorkdir) - } - if !strings.Contains(app.state.StatusText, "Session workspace switched") { - t.Fatalf("expected session workspace switch status, got %q", app.state.StatusText) - } - }) -} - -func TestRunSessionWorkdirCommandBranches(t *testing.T) { - t.Run("invalid slash command returns parser error", func(t *testing.T) { - msg := runSessionWorkdirCommand(newStubRuntime(), "", "", "/status")() - result, ok := msg.(sessionWorkdirResultMsg) - if !ok { - t.Fatalf("expected sessionWorkdirResultMsg, got %T", msg) - } - if result.err == nil || !strings.Contains(result.err.Error(), "unknown command") { - t.Fatalf("expected unknown command error, got %+v", result) - } - }) - - t.Run("empty workspace query without current workdir returns usage", func(t *testing.T) { - msg := runSessionWorkdirCommand(newStubRuntime(), "", "", "/cwd")() - result := msg.(sessionWorkdirResultMsg) - if result.err == nil || !strings.Contains(result.err.Error(), "usage: /cwd ") { - t.Fatalf("expected usage error, got %+v", result) - } - }) - - t.Run("empty workspace query with current workdir shows current directory", func(t *testing.T) { - current := t.TempDir() - msg := runSessionWorkdirCommand(newStubRuntime(), "", current, "/cwd")() - result := msg.(sessionWorkdirResultMsg) - if result.err != nil { - t.Fatalf("unexpected error: %v", result.err) - } - if result.workdir != current || !strings.Contains(result.notice, "Current workspace is") { - t.Fatalf("expected current workspace message, got %+v", result) - } - }) - - t.Run("session workdir change surfaces runtime error", func(t *testing.T) { - runtime := newStubRuntime() - runtime.setWorkdirErr = errors.New("set workdir failed") - msg := runSessionWorkdirCommand(runtime, "session-1", t.TempDir(), "/cwd ./subdir")() - result := msg.(sessionWorkdirResultMsg) - if result.err == nil || !strings.Contains(result.err.Error(), "set workdir failed") { - t.Fatalf("expected set workdir error, got %+v", result) - } - }) - - t.Run("session workdir fallback uses current workdir when runtime returns empty", func(t *testing.T) { - current := t.TempDir() - runtime := newStubRuntime() - runtime.setResult = &agentsession.Session{ID: "session-1", Workdir: ""} - msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() - result := msg.(sessionWorkdirResultMsg) - if result.err != nil { - t.Fatalf("unexpected error: %v", result.err) - } - if result.workdir != current { - t.Fatalf("expected fallback workdir %q, got %q", current, result.workdir) - } - }) - - t.Run("session workdir change uses runtime returned workdir when available", func(t *testing.T) { - current := t.TempDir() - target := t.TempDir() - runtime := newStubRuntime() - runtime.setResult = &agentsession.Session{ID: "session-1", Workdir: target} - msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() - result := msg.(sessionWorkdirResultMsg) - if result.err != nil { - t.Fatalf("unexpected error: %v", result.err) - } - if result.workdir != target || !strings.Contains(result.notice, "Session workspace switched") { - t.Fatalf("expected runtime returned workdir %q, got %+v", target, result) - } - }) - - t.Run("draft workspace change returns resolve error for missing path", func(t *testing.T) { - msg := runSessionWorkdirCommand(newStubRuntime(), "", t.TempDir(), "/cwd ./missing-path")() - result := msg.(sessionWorkdirResultMsg) - if result.err == nil || !strings.Contains(strings.ToLower(result.err.Error()), "resolve path") { - t.Fatalf("expected resolve path error, got %+v", result) - } - }) -} - -func TestResolveWorkspacePathAndSelector(t *testing.T) { - t.Run("resolve from empty base falls back to process cwd", func(t *testing.T) { - resolved, err := resolveWorkspacePath("", ".") - if err != nil { - t.Fatalf("resolveWorkspacePath() error = %v", err) - } - expected, err := filepath.Abs(".") - if err != nil { - t.Fatalf("filepath.Abs(.) error = %v", err) - } - if resolved != filepath.Clean(expected) { - t.Fatalf("expected %q, got %q", filepath.Clean(expected), resolved) - } - }) - - t.Run("resolve relative path from base", func(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "sub") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("mkdir target: %v", err) - } - resolved, err := resolveWorkspacePath(base, "sub") - if err != nil { - t.Fatalf("resolveWorkspacePath() error = %v", err) - } - if resolved != filepath.Clean(target) { - t.Fatalf("expected %q, got %q", filepath.Clean(target), resolved) - } - }) - - t.Run("resolve workspace path rejects non-directory", func(t *testing.T) { - base := t.TempDir() - file := filepath.Join(base, "note.txt") - if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { - t.Fatalf("write file: %v", err) - } - _, err := resolveWorkspacePath(base, "note.txt") - if err == nil || !strings.Contains(err.Error(), "is not a directory") { - t.Fatalf("expected non-directory error, got %v", err) - } - }) - - t.Run("resolve workspace path returns error for missing target", func(t *testing.T) { - base := t.TempDir() - _, err := resolveWorkspacePath(base, "missing-dir") - if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve path") { - t.Fatalf("expected missing path error, got %v", err) - } - }) - - t.Run("select session workdir prefers session value", func(t *testing.T) { - if got := selectSessionWorkdir(" /session ", "/default"); got != "/session" { - t.Fatalf("expected trimmed session workdir, got %q", got) - } - if got := selectSessionWorkdir("", " /default "); got != "/default" { - t.Fatalf("expected fallback default workdir, got %q", got) - } - }) -} - -func TestAppUpdateSessionWorkdirResultMessage(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - model, cmd := app.Update(sessionWorkdirResultMsg{ - notice: "ok", - workdir: filepath.Join(t.TempDir(), "missing-dir"), - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.state.ExecutionError == "" || !strings.Contains(strings.ToLower(app.state.ExecutionError), "missing-dir") { - t.Fatalf("expected refresh workspace file error, got %q", app.state.ExecutionError) - } - if app.state.StatusText == "ok" { - t.Fatalf("expected status text to switch to refresh error, got %q", app.state.StatusText) - } -} - -func TestRunAgentWorkdirForwarding(t *testing.T) { - t.Run("draft run forwards current workdir", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - target := t.TempDir() - app.state.CurrentWorkdir = target - app.input.SetValue("hello") - app.state.InputText = "hello" - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 { - t.Fatalf("expected one runtime input, got %d", len(runtime.runInputs)) - } - if runtime.runInputs[0].Workdir != target { - t.Fatalf("expected draft run workdir %q, got %q", target, runtime.runInputs[0].Workdir) - } - }) - - t.Run("session run uses persisted session workdir", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.state.ActiveSessionID = "session-1" - app.state.CurrentWorkdir = t.TempDir() - app.input.SetValue("hello") - app.state.InputText = "hello" - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 { - t.Fatalf("expected one runtime input, got %d", len(runtime.runInputs)) - } - if runtime.runInputs[0].Workdir != "" { - t.Fatalf("expected session run to omit workdir override, got %q", runtime.runInputs[0].Workdir) - } - }) -} - -func TestHandlePermissionDecisionKey(t *testing.T) { - t.Parallel() - - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.pendingPermission = &pendingPermissionPrompt{ - RequestID: "perm-1", - ToolName: "webfetch", - } - - tests := []struct { - name string - key tea.KeyMsg - wantSent agentruntime.PermissionResolutionDecision - handled bool - }{ - { - name: "y maps to allow once", - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}, - wantSent: agentruntime.PermissionResolutionAllowOnce, - handled: true, - }, - { - name: "a maps to allow session", - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, - wantSent: agentruntime.PermissionResolutionAllowSession, - handled: true, - }, - { - name: "n maps to reject", - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}, - wantSent: agentruntime.PermissionResolutionReject, - handled: true, - }, - { - name: "other key ignored", - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, - handled: false, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - app.pendingPermission.Submitted = false - runtime.resolveInputs = nil - - cmd, handled := app.handlePermissionDecisionKey(tt.key) - if handled != tt.handled { - t.Fatalf("expected handled=%v, got %v", tt.handled, handled) - } - if !tt.handled { - if cmd != nil { - t.Fatalf("expected nil cmd for unhandled key") - } - return - } - if cmd == nil { - t.Fatalf("expected resolve cmd") - } - msg := cmd() - result, ok := msg.(permissionResolveResultMsg) - if !ok { - t.Fatalf("expected permissionResolveResultMsg, got %T", msg) - } - if result.err != nil { - t.Fatalf("expected nil resolve error, got %v", result.err) - } - if len(runtime.resolveInputs) == 0 { - t.Fatalf("expected runtime resolve inputs") - } - last := runtime.resolveInputs[len(runtime.resolveInputs)-1] - if last.Decision != tt.wantSent { - t.Fatalf("expected decision %q, got %q", tt.wantSent, last.Decision) - } - if strings.TrimSpace(last.RequestID) != "perm-1" { - t.Fatalf("expected request id perm-1, got %q", last.RequestID) - } - }) - } -} - -func TestHandlePermissionDecisionKeyIgnoresRepeatedSubmission(t *testing.T) { - t.Parallel() - - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.pendingPermission = &pendingPermissionPrompt{ - RequestID: "perm-repeat", - ToolName: "webfetch", - } - - cmd, handled := app.handlePermissionDecisionKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) - if !handled || cmd == nil { - t.Fatalf("expected first permission key to be handled with cmd") - } - msg := cmd() - result, ok := msg.(permissionResolveResultMsg) - if !ok || result.err != nil { - t.Fatalf("expected successful permissionResolveResultMsg, got %#v", msg) - } - if len(runtime.resolveInputs) != 1 { - t.Fatalf("expected one resolve call after first submission, got %d", len(runtime.resolveInputs)) - } - - cmd, handled = app.handlePermissionDecisionKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) - if !handled { - t.Fatalf("expected repeated permission key to be consumed") - } - if cmd != nil { - t.Fatalf("expected repeated submission to skip runtime command") - } - if len(runtime.resolveInputs) != 1 { - t.Fatalf("expected resolve call count unchanged after repeat, got %d", len(runtime.resolveInputs)) - } -} - -func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, app *App, runtime *stubRuntime) - msg tea.Msg - assert func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) - }{ - { - name: "escape closes model picker", - setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.ActivePicker = pickerModel - app.focus = panelInput - }, - msg: tea.KeyMsg{Type: tea.KeyEsc}, - assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { - t.Helper() - if app.state.ActivePicker != pickerNone { - t.Fatalf("expected model picker to close") - } - if app.state.Focus != panelInput { - t.Fatalf("expected focus to return to input") - } - }, - }, - { - name: "runtime chunk appends assistant draft", - setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.ActiveSessionID = "session-1" - }, - msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventAgentChunk, - SessionID: "session-1", - Payload: "hello", - }}, - assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { - t.Helper() - if len(app.activeMessages) == 0 { - t.Fatalf("expected assistant draft message") - } - last := app.activeMessages[len(app.activeMessages)-1] - if last.Role != roleAssistant || last.Content != "hello" { - t.Fatalf("unexpected last assistant draft: %+v", last) - } - }, - }, - { - name: "runtime done appends final assistant and clears running state", - setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.IsAgentRunning = true - app.state.ActiveSessionID = "session-2" - }, - msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventAgentDone, - SessionID: "session-2", - Payload: providertypes.Message{ - Role: roleAssistant, - Content: "final", - }, - }}, - assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { - t.Helper() - if app.state.IsAgentRunning { - t.Fatalf("expected agent to stop running") - } - if app.state.StatusText != statusReady { - t.Fatalf("expected status ready, got %q", app.state.StatusText) - } - last := app.activeMessages[len(app.activeMessages)-1] - if last.Content != "final" { - t.Fatalf("expected final assistant message, got %+v", last) - } - }, - }, - { - name: "runtime canceled clears running state without error", - setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.IsAgentRunning = true - app.state.ActiveSessionID = "session-cancel" - }, - msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventRunCanceled, - SessionID: "session-cancel", - }}, - assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { - t.Helper() - if app.state.IsAgentRunning { - t.Fatalf("expected agent to stop running") - } - if app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { - t.Fatalf("expected canceled status without error, got %+v", app.state) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected cancel notice to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Canceled current run" { - t.Fatalf("expected cancel notice in activity, got %+v", app.activities) - } - }, - }, - { - name: "runtime tool result error is surfaced", - setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.ActiveSessionID = "session-3" - }, - msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolResult, - SessionID: "session-3", - Payload: tools.ToolResult{ - Name: "filesystem_edit", - Content: "boom", - IsError: true, - }, - }}, - assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { - t.Helper() - if app.state.ExecutionError != "boom" { - t.Fatalf("expected execution error boom, got %q", app.state.ExecutionError) - } - if app.state.StatusText != statusToolError { - t.Fatalf("expected status tool error, got %q", app.state.StatusText) - } - if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleTool { - t.Fatalf("expected tool result to stay in transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Tool error" { - t.Fatalf("expected tool error in activity, got %+v", app.activities) - } - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - if tt.setup != nil { - tt.setup(t, &app, runtime) - } - - model, cmd := app.Update(tt.msg) - app = model.(App) - tt.assert(t, runtime, app, collectTeaMessages(cmd)) - }) - } -} - -func TestAppHelpersAndRenderingSmoke(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - now := agentsession.Session{ - ID: "session-1", - Title: "Existing Session", - Messages: []providertypes.Message{ - {Role: roleUser, Content: "hi"}, - {Role: roleAssistant, Content: "hello"}, - }, - } - runtime.sessions = []agentsession.Summary{ - {ID: now.ID, Title: now.Title, UpdatedAt: now.UpdatedAt}, - } - runtime.loads[now.ID] = now - - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - if app.Init() == nil { - t.Fatalf("expected init command") - } - - app.openModelPicker() - app.closePicker() - app.selectCurrentModel(config.OpenAIDefaultModel) - app.appendAssistantChunk("hello") - app.appendAssistantChunk(" world") - if !app.lastAssistantMatches("hello world") { - t.Fatalf("expected assistant draft to match") - } - app.appendInlineMessage(roleSystem, "notice") - - app.focusNext() - app.focusPrev() - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyDown}) - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyUp}) - - if err := app.refreshSessions(); err != nil { - t.Fatalf("refreshSessions() error = %v", err) - } - if err := app.activateSelectedSession(); err != nil { - t.Fatalf("activateSelectedSession() error = %v", err) - } - app.syncActiveSessionTitle() - app.syncConfigState(manager.Get()) - app.rebuildTranscript() - - view := app.View() - if view == "" { - t.Fatalf("expected non-empty View()") - } - if lipgloss.Height(view) > app.height+1 { - t.Fatalf("expected view height to stay within window bounds, got %d", lipgloss.Height(view)) - } - lines := strings.Split(strings.TrimRight(view, "\n"), "\n") - if len(lines) == 0 || !strings.Contains(lines[len(lines)-1], "Ctrl+U") { - t.Fatalf("expected footer help to render on the last visible line") - } - if app.renderHeader(app.computeLayout().contentWidth) == "" || app.renderBody(app.computeLayout()) == "" { - t.Fatalf("expected non-empty render output") - } - app.state.ActivePicker = pickerModel - if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { - t.Fatalf("expected model picker rendering") - } - app.state.ActivePicker = pickerNone - app.refreshCommandMenu() - if app.renderCommandMenu(80) == "" { - app.input.SetValue("/") - app.state.InputText = "/" - app.refreshCommandMenu() - if app.renderCommandMenu(80) == "" { - t.Fatalf("expected slash command menu when input starts with slash") - } - } - app.input.SetValue("/status") - app.state.InputText = "/status" - app.refreshCommandMenu() - if menu := app.renderCommandMenu(80); menu != "" { - t.Fatalf("expected complete slash command to hide menu, got %q", menu) - } - if app.renderPrompt(80) == "" || app.renderHelp(80) == "" { - t.Fatalf("expected prompt and help output") - } - app.state.StatusText = "Status:\nSession: Draft\nProvider: openll" - if lipgloss.Height(app.renderHeader(app.computeLayout().contentWidth)) != 1 { - t.Fatalf("expected header to remain a single line even with multiline status text") - } - if lipgloss.Width(app.renderPrompt(80)) != 80 { - t.Fatalf("expected prompt width 80, got %d", lipgloss.Width(app.renderPrompt(80))) - } - if got := newKeyMap().Send.Help().Key; got != "Enter" { - t.Fatalf("expected send shortcut help to use Enter, got %q", got) - } - if got := newKeyMap().Send.Keys(); len(got) != 1 || got[0] != "enter" { - t.Fatalf("expected send binding to use enter, got %+v", got) - } - if got := newKeyMap().Newline.Help().Key; got != "Ctrl+J" { - t.Fatalf("expected newline shortcut help to use Ctrl+J, got %q", got) - } - if got := newKeyMap().Newline.Keys(); len(got) != 1 || got[0] != "ctrl+j" { - t.Fatalf("expected newline binding to use ctrl+j, got %+v", got) - } - if !strings.Contains(app.renderHelp(80), "Ctrl+J") { - t.Fatalf("expected footer help to render newline shortcut") - } - sidebar := app.renderSidebar(26, 12) - if lipgloss.Width(sidebar) != 26 || lipgloss.Height(sidebar) != 12 { - t.Fatalf("expected sidebar to respect requested dimensions, got %dx%d", lipgloss.Width(sidebar), lipgloss.Height(sidebar)) - } - if !strings.Contains(app.renderSidebar(26, 12), sidebarTitle) || !strings.Contains(app.renderSidebar(26, 12), sidebarOpenHint) { - t.Fatalf("expected updated sidebar header text") - } - if strings.Contains(app.renderPrompt(80), "Enter sends, Ctrl+J inserts a newline") { - t.Fatalf("expected keyboard hint to move out of placeholder text") - } - if strings.TrimSpace(app.renderPrompt(80)) == "" { - t.Fatalf("expected prompt to render a visible border") - } - app.input.SetValue("one") - app.state.InputText = "one" - app.applyComponentLayout(true) - if app.input.Height() != 1 { - t.Fatalf("expected single-line composer height 1, got %d", app.input.Height()) - } - if strings.Count(app.renderPrompt(80), "> ") < 1 { - t.Fatalf("expected single-line prompt to render composer prefix") - } - app.input.SetValue("one\ntwo") - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - if app.input.Height() != 2 { - t.Fatalf("expected two-line composer height 2, got %d", app.input.Height()) - } - if strings.Count(app.renderPrompt(80), "> ") < 2 { - t.Fatalf("expected multi-line prompt to repeat composer prefix") - } - app.input.SetValue(strings.Join([]string{"1", "2", "3", "4", "5", "6"}, "\n")) - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - if app.input.Height() != composerMaxHeight { - t.Fatalf("expected composer height capped at %d, got %d", composerMaxHeight, app.input.Height()) - } - if app.focusLabel() == "" || app.statusBadge("ready") == "" { - t.Fatalf("expected status helpers to render") - } - app.focus = panelSessions - if app.focusLabel() != focusLabelSessions { - t.Fatalf("expected session focus label") - } - app.focus = panelTranscript - if app.focusLabel() != focusLabelTranscript { - t.Fatalf("expected transcript focus label") - } - app.focus = panelInput - if app.statusBadge("error: boom") == "" || app.statusBadge("running now") == "" { - t.Fatalf("expected status badge variants") - } - if rendered, _ := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleError, Content: "boom"}, 80, 1); rendered == "" { - t.Fatalf("expected error message block") - } - if rendered, _ := app.renderMessageBlockWithCopy(providertypes.Message{ - Role: roleAssistant, - ToolCalls: []providertypes.ToolCall{ - {Name: "filesystem_edit"}, - }, - }, 80, 1); rendered == "" { - t.Fatalf("expected tool call message block") - } - renderedCodeOnly, _ := app.renderMessageContentWithCopy("```go\nfmt.Println(\"x\")\n```", 80, app.styles.messageBody, 1) - if renderedCodeOnly == "" { - t.Fatalf("expected code block rendering") - } - if app.computeLayout().contentWidth == 0 { - t.Fatalf("expected computed layout") - } - app.width = 90 - app.height = 26 - compact := app.computeLayout() - if !compact.stacked { - t.Fatalf("expected compact layout to stack") - } - app.sessions.SetFilterState(list.Filtering) - if !app.isFilteringSessions() { - t.Fatalf("expected filtering state") - } - if app.sessions.ShowPagination() { - t.Fatalf("expected sessions pagination to stay hidden") - } -} - -func TestTUIStandaloneHelpers(t *testing.T) { - t.Parallel() - - if len(newKeyMap().ShortHelp()) == 0 || len(newKeyMap().FullHelp()) == 0 { - t.Fatalf("expected key help bindings") - } - - if wrapPlain("abcdef", 3) == "" || trimRunes("abcdef", 4) == "" || trimMiddle("abcdefgh", 5) == "" { - t.Fatalf("expected string helpers to return content") - } - if fallback("", "x") != "x" { - t.Fatalf("expected fallback to use replacement") - } - if preview("line1\nline2\nline3", 8, 2) == "" { - t.Fatalf("expected preview output") - } - if clamp(10, 0, 5) != 5 || max(2, 3) != 3 { - t.Fatalf("expected numeric helpers to work") - } - - sItem := sessionItem{Summary: agentsession.Summary{Title: "My Session"}} - if sItem.FilterValue() != "my session" { - t.Fatalf("unexpected session item filter value") - } - - mItem := selectionItem{name: "gpt-5.4", description: "Frontier"} - if mItem.Title() == "" || mItem.Description() == "" || mItem.FilterValue() == "" { - t.Fatalf("expected model item helpers to return values") - } - - delegate := sessionDelegate{styles: newStyles()} - if delegate.Height() == 0 || delegate.Spacing() == 0 { - t.Fatalf("expected delegate sizing") - } - if delegate.Update(nil, nil) != nil { - t.Fatalf("expected delegate update to return nil") - } - var buf bytes.Buffer - model := newSelectionPickerItems(mapModelItems([]config.ModelDescriptor{{ID: "gpt-4.1", Name: "gpt-4.1"}})) - sessionList := []list.Item{sItem} - listModel := list.New(sessionList, delegate, 30, 10) - delegate.Render(&buf, listModel, 0, sItem) - if buf.Len() == 0 { - t.Fatalf("expected delegate render output") - } - - eventCh := make(chan agentruntime.RuntimeEvent, 1) - eventCh <- agentruntime.RuntimeEvent{Type: agentruntime.EventAgentChunk, Payload: "x"} - if msg := ListenForRuntimeEvent(eventCh)(); msg == nil { - t.Fatalf("expected runtime event message") - } - close(eventCh) - if _, ok := ListenForRuntimeEvent(eventCh)().(RuntimeClosedMsg); !ok { - t.Fatalf("expected runtime closed message") - } - - runtime := newStubRuntime() - runMsg := runAgent(runtime, "session-x", "", "hello")() - if _, ok := runMsg.(runFinishedMsg); !ok { - t.Fatalf("expected runFinishedMsg") - } - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "hello" { - t.Fatalf("expected runtime run input to be captured") - } - - manager := newTestConfigManager(t) - msg := runModelSelection(newTestProviderService(t, manager), config.OpenAIDefaultModel)() - if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { - t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) - } - - _ = model -} - -func TestAppUpdateAdditionalTransitions(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) - msg tea.Msg - assert func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) - }{ - { - name: "window resize updates dimensions", - msg: tea.WindowSizeMsg{Width: 100, Height: 32}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.width != 100 || app.height != 32 { - t.Fatalf("expected updated dimensions, got %dx%d", app.width, app.height) - } - }, - }, - { - name: "runtime closed stops agent", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.state.IsAgentRunning = true - app.state.StatusText = "" - }, - msg: RuntimeClosedMsg{}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.IsAgentRunning || app.state.StatusText != statusRuntimeClosed { - t.Fatalf("expected runtime closed state, got %+v", app.state) - } - }, - }, - { - name: "run finished canceled is not surfaced as error", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.state.IsAgentRunning = true - app.state.StatusText = statusCanceling - }, - msg: runFinishedMsg{err: context.Canceled}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.IsAgentRunning || app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { - t.Fatalf("expected canceled run to stop without error, got %+v", app.state) - } - }, - }, - { - name: "run finished error is surfaced", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.state.IsAgentRunning = true - }, - msg: runFinishedMsg{err: context.DeadlineExceeded}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.IsAgentRunning || app.state.ExecutionError == "" { - t.Fatalf("expected execution error to be set") - } - }, - }, - { - name: "model selection success updates state", - msg: localCommandResultMsg{notice: "[System] ok"}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.StatusText != "[System] ok" { - t.Fatalf("expected success notice, got %q", app.state.StatusText) - } - }, - }, - { - name: "model selection error updates state", - msg: localCommandResultMsg{err: context.Canceled}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.ExecutionError == "" || app.state.StatusText == "" { - t.Fatalf("expected local command error state") - } - }, - }, - { - name: "cancel shortcut interrupts running agent", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.state.IsAgentRunning = true - app.state.StatusText = statusThinking - runtime.cancelResult = true - app.keys.CancelAgent.SetKeys("ctrl+@") - }, - msg: tea.KeyMsg{Type: tea.KeyCtrlAt}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if runtime.cancelCalls != 1 { - t.Fatalf("expected cancel to be called once, got %d", runtime.cancelCalls) - } - if app.state.StatusText != statusCanceling { - t.Fatalf("expected canceling status, got %q", app.state.StatusText) - } - }, - }, - { - name: "toggle help flips state", - msg: tea.KeyMsg{Type: tea.KeyCtrlQ}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if !app.state.ShowHelp || !app.help.ShowAll { - t.Fatalf("expected help to be visible") - } - }, - }, - { - name: "next panel moves focus", - msg: tea.KeyMsg{Type: tea.KeyTab}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.focus != panelSessions { - t.Fatalf("expected focus to move to sessions, got %v", app.focus) - } - }, - }, - { - name: "tab in non-empty input inserts indentation instead of switching panel", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.focus = panelInput - app.applyFocus() - app.input.SetValue("func main() {\n") - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyTab}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.focus != panelInput { - t.Fatalf("expected focus to stay in input, got %v", app.focus) - } - if !strings.Contains(app.state.InputText, "func main() {") { - t.Fatalf("expected existing code to be preserved, got %q", app.state.InputText) - } - if !strings.HasSuffix(app.state.InputText, "\n\t") && !strings.HasSuffix(app.state.InputText, "\n ") { - t.Fatalf("expected tab indentation at the end, got %q", app.state.InputText) - } - }, - }, - { - name: "previous panel moves focus backward", - msg: tea.KeyMsg{Type: tea.KeyShiftTab}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.focus != panelActivity { - t.Fatalf("expected focus to move backward, got %v", app.focus) - } - }, - }, - { - name: "new session clears active draft", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.state.ActiveSessionID = "existing" - app.state.ActiveSessionTitle = "Existing" - app.activeMessages = []providertypes.Message{{Role: roleUser, Content: "hello"}} - }, - msg: tea.KeyMsg{Type: tea.KeyCtrlN}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.ActiveSessionID != "" || len(app.activeMessages) != 0 || app.state.StatusText != statusDraft { - t.Fatalf("expected new draft state, got %+v", app.state) - } - }, - }, - { - name: "session enter activates selected session", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - runtime.sessions = []agentsession.Summary{{ID: "s1", Title: "One"}} - runtime.loads["s1"] = agentsession.Session{ - ID: "s1", - Title: "One", - Messages: []providertypes.Message{{Role: roleAssistant, Content: "loaded"}}, - } - if err := app.refreshSessions(); err != nil { - t.Fatalf("refresh sessions: %v", err) - } - app.focus = panelSessions - app.applyFocus() - }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.ActiveSessionID != "s1" || len(app.activeMessages) != 1 { - t.Fatalf("expected selected session to load, got %+v / %+v", app.state, app.activeMessages) - } - }, - }, - { - name: "transcript focus handles scroll keys", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.focus = panelTranscript - app.transcript.SetContent(strings.Repeat("line\n", 80)) - app.transcript.Height = 5 - app.transcript.GotoBottom() - }, - msg: tea.KeyMsg{Type: tea.KeyUp}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.transcript.YOffset < 0 { - t.Fatalf("expected non-negative offset") - } - }, - }, - { - name: "input typing updates composer text", - msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.InputText != "h" { - t.Fatalf("expected input text to update, got %q", app.state.InputText) - } - }, - }, - { - name: "ctrl+j inserts newline without sending", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue("inspect repo") - app.state.InputText = "inspect repo" - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if len(runtime.runInputs) != 0 { - t.Fatalf("expected ctrl+j not to send input, got %+v", runtime.runInputs) - } - if app.state.InputText != "inspect repo\n" { - t.Fatalf("expected newline to be inserted, got %q", app.state.InputText) - } - if app.input.Height() != 2 { - t.Fatalf("expected composer height to grow to 2, got %d", app.input.Height()) - } - prompt := app.renderPrompt(80) - if !strings.Contains(prompt, "inspect repo") { - t.Fatalf("expected first line to remain visible after newline, got %q", prompt) - } - if strings.Count(prompt, "> ") < 2 { - t.Fatalf("expected both lines to keep prompt prefix, got %q", prompt) - } - }, - }, - { - name: "second ctrl+j grows composer to third line", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue("line1\nline2") - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if len(runtime.runInputs) != 0 { - t.Fatalf("expected second ctrl+j not to send input") - } - if app.input.Height() != 3 { - t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) - } - prompt := app.renderPrompt(80) - if !strings.Contains(prompt, "line1") || !strings.Contains(prompt, "line2") { - t.Fatalf("expected previous lines to remain visible, got %q", prompt) - } - if strings.Count(prompt, "> ") < 3 { - t.Fatalf("expected all three lines to keep prompt prefix, got %q", prompt) - } - }, - }, - { - name: "plain multiline input enter starts runtime", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue("inspect repo\nwith details") - app.state.InputText = "inspect repo\nwith details" - }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if !app.state.IsAgentRunning { - t.Fatalf("expected agent to start running") - } - if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleUser { - t.Fatalf("expected user message appended") - } - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "inspect repo\nwith details" { - t.Fatalf("expected runtime command to execute once, got %+v", runtime.runInputs) - } - finished := false - for _, msg := range msgs { - if _, ok := msg.(runFinishedMsg); ok { - finished = true - } - } - if !finished { - t.Fatalf("expected runFinishedMsg from command") - } - }, - }, - { - name: "blank input enter stays local", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue(" \n ") - app.state.InputText = " \n " - }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { - t.Fatalf("expected blank input enter not to send") - } - if app.state.InputText != " \n " { - t.Fatalf("expected blank input to stay unchanged on enter, got %q", app.state.InputText) - } - }, - }, - { - name: "blank input ctrl+j inserts newline locally", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue(" \n ") - app.state.InputText = " \n " - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { - t.Fatalf("expected blank input ctrl+j not to send") - } - if app.state.InputText != " \n \n" { - t.Fatalf("expected ctrl+j to insert newline into blank input, got %q", app.state.InputText) - } - if app.input.Height() != 3 { - t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) - } - }, - }, - { - name: "delete newline shrinks composer height", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue("line1\n") - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyBackspace}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.input.Height() != 1 { - t.Fatalf("expected composer height to shrink to 1, got %d", app.input.Height()) - } - }, - }, - { - name: "quit returns quit command", - msg: tea.KeyMsg{Type: tea.KeyCtrlU}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - foundQuit := false - for _, msg := range msgs { - if _, ok := msg.(tea.QuitMsg); ok { - foundQuit = true - } - } - if !foundQuit { - t.Fatalf("expected quit message") - } - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - if tt.setup != nil { - tt.setup(t, &app, runtime, manager) - } - - model, cmd := app.Update(tt.msg) - app = model.(App) - tt.assert(t, app, runtime, manager, collectTeaMessages(cmd)) - }) - } -} - -func TestAppUpdatePasteEnterGuard(t *testing.T) { - t.Run("paste-like burst keeps enter as newline", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - for _, r := range []rune("function_name") { - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) - app = model.(App) - _ = collectTeaMessages(cmd) - now = now.Add(15 * time.Millisecond) - } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected enter to stay local during paste-like burst, got %+v", runtime.runInputs) - } - if app.state.InputText != "function_name\n" { - t.Fatalf("expected enter to insert newline, got %q", app.state.InputText) - } - if app.state.IsAgentRunning { - t.Fatalf("expected agent to remain idle") - } - }) - - t.Run("normal typing enter still sends", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - for _, r := range []rune("hello") { - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) - app = model.(App) - _ = collectTeaMessages(cmd) - now = now.Add(300 * time.Millisecond) - } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - msgs := collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "hello" { - t.Fatalf("expected enter to send normal input once, got %+v", runtime.runInputs) - } - if app.state.InputText != "" { - t.Fatalf("expected input to reset after send, got %q", app.state.InputText) - } - for _, msg := range msgs { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - }) - - t.Run("explicit paste enter inserts newline", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.input.SetValue("before") - app.state.InputText = "before" - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter, Paste: true}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected paste enter not to send, got %+v", runtime.runInputs) - } - if app.state.InputText != "before\n" { - t.Fatalf("expected paste enter to insert newline, got %q", app.state.InputText) - } - }) - - t.Run("segmented long paste keeps enter as newline across chunks", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("package main")}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(80 * time.Millisecond) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(80 * time.Millisecond) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("func main() {}")}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(80 * time.Millisecond) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected segmented paste not to trigger send, got %+v", runtime.runInputs) - } - if app.state.InputText != "package main\nfunc main() {}\n" { - t.Fatalf("expected multiline pasted content, got %q", app.state.InputText) - } - }) - - t.Run("long gap after paste-like input allows immediate send", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("line1")}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(2 * time.Second) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "line1" { - t.Fatalf("expected enter to send after long gap, got %+v", runtime.runInputs) - } - }) - - t.Run("enter sends after paste session expires", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("line1\nline2")}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(pasteSessionGuard + 100*time.Millisecond) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "line1\nline2" { - t.Fatalf("expected send after paste session expiry, got %+v", runtime.runInputs) - } - }) - - t.Run("enter sends right after tab indentation in normal input", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.input.SetValue("hello") - app.state.InputText = app.input.Value() - app.focus = panelInput - app.applyFocus() - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyTab}) - app = model.(App) - _ = collectTeaMessages(cmd) - if !strings.Contains(app.state.InputText, "hello") { - t.Fatalf("expected tab to keep current input, got %q", app.state.InputText) - } - - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - if len(runtime.runInputs) != 1 { - t.Fatalf("expected enter to send after tab indentation, got %+v", runtime.runInputs) - } - }) -} - -func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { - manager := newTestConfigManager(t) - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.CurrentModel = "unsupported-current" - return nil - }); err != nil { - t.Fatalf("set unsupported current model: %v", err) - } - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.openModelPicker() - if len(app.modelPicker.Items()) == 0 { - t.Fatalf("expected model picker catalog") - } - selected := app.modelPicker.Items()[0].(selectionItem).id - app.modelPicker.Select(0) - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - if app.state.ActivePicker != pickerNone { - t.Fatalf("expected picker to close after selection") - } - - if cmd != nil { - if msg := cmd(); msg != nil { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - } - - cfg := manager.Get() - if cfg.CurrentModel != selected { - t.Fatalf("expected current model %q, got %q", selected, cfg.CurrentModel) - } -} - -func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { - manager := newTestConfigManager(t) - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.CurrentModel = "unsupported-current" - return nil - }); err != nil { - t.Fatalf("set unsupported current model: %v", err) - } - - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.openProviderPicker() - if len(app.providerPicker.Items()) < 2 { - t.Fatalf("expected provider picker catalog") - } - selectedIndex := -1 - selected := "" - for idx, item := range app.providerPicker.Items() { - candidate, ok := item.(selectionItem) - if !ok { - continue - } - if candidate.id == config.QiniuName { - selectedIndex = idx - selected = candidate.id - break - } - } - if selectedIndex < 0 { - t.Fatalf("expected provider picker to include %s", config.QiniuName) - } - app.providerPicker.Select(selectedIndex) - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - if app.state.ActivePicker != pickerNone { - t.Fatalf("expected picker to close after selection") - } - - for _, msg := range collectTeaMessages(cmd) { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - - cfg := manager.Get() - if cfg.SelectedProvider != selected { - t.Fatalf("expected selected provider %q, got %q", selected, cfg.SelectedProvider) - } - if cfg.CurrentModel != config.QiniuDefaultModel { - t.Fatalf("expected current model to follow provider default, got %q", cfg.CurrentModel) - } -} - -func TestRefreshPickerKeepsSizeAfterRebuild(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.providerPicker.SetSize(32, 9) - app.modelPicker.SetSize(28, 7) - - if err := app.refreshProviderPicker(); err != nil { - t.Fatalf("refreshProviderPicker() error = %v", err) - } - if err := app.refreshModelPicker(); err != nil { - t.Fatalf("refreshModelPicker() error = %v", err) - } - - if app.providerPicker.Width() != 32 || app.providerPicker.Height() != 9 { - t.Fatalf("expected provider picker size 32x9, got %dx%d", app.providerPicker.Width(), app.providerPicker.Height()) - } - if app.modelPicker.Width() != 28 || app.modelPicker.Height() != 7 { - t.Fatalf("expected model picker size 28x7, got %dx%d", app.modelPicker.Width(), app.modelPicker.Height()) - } -} - -func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { - tests := []struct { - name string - event agentruntime.RuntimeEvent - setup func(*App) - assert func(t *testing.T, app App) - }{ - { - name: "user message resets execution state", - setup: func(app *App) { - app.state.ExecutionError = "old" - app.state.CurrentTool = "bash" - }, - event: agentruntime.RuntimeEvent{Type: agentruntime.EventUserMessage, SessionID: "s1"}, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.ExecutionError != "" || app.state.CurrentTool != "" || app.state.StatusText != statusThinking { - t.Fatalf("unexpected user message state: %+v", app.state) - } - }, - }, - { - name: "tool start stores current tool", - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolStart, - SessionID: "s1", - Payload: providertypes.ToolCall{ - Name: "filesystem_edit", - }, - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.CurrentTool != "filesystem_edit" || app.state.StatusText != statusRunningTool { - t.Fatalf("unexpected tool start state: %+v", app.state) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected tool start to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Running tool" { - t.Fatalf("expected tool start in activity, got %+v", app.activities) - } - }, - }, - { - name: "tool success appends completion event", - setup: func(app *App) { - app.state.CurrentTool = "filesystem_edit" - }, - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolResult, - SessionID: "s1", - Payload: tools.ToolResult{ - Name: "filesystem_edit", - }, - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.CurrentTool != "" || app.state.StatusText != statusToolFinished { - t.Fatalf("unexpected tool success state: %+v", app.state) - } - if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleTool { - t.Fatalf("expected tool result message in transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Completed tool" { - t.Fatalf("expected tool completion in activity, got %+v", app.activities) - } - }, - }, - { - name: "error event appends inline error", - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventError, - SessionID: "s1", - Payload: "boom", - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.ExecutionError != "boom" || app.state.IsAgentRunning { - t.Fatalf("unexpected error state: %+v", app.state) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected runtime error to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Runtime error" { - t.Fatalf("expected runtime error in activity, got %+v", app.activities) - } - }, - }, - { - name: "tool call thinking is tracked as activity", - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolCallThinking, - SessionID: "s1", - Payload: "filesystem_edit", - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.CurrentTool != "filesystem_edit" { - t.Fatalf("expected current tool to be populated, got %+v", app.state) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Planning tool call" { - t.Fatalf("expected planning activity, got %+v", app.activities) - } - }, - }, - { - name: "provider retry is tracked as activity", - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventProviderRetry, - SessionID: "s1", - Payload: "retrying provider call (attempt 1/2, wait=1.0s)...", - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.StatusText != statusThinking { - t.Fatalf("expected provider retry to preserve thinking status, got %+v", app.state) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Retrying provider call" { - t.Fatalf("expected provider retry activity, got %+v", app.activities) - } - }, - }, - { - name: "run canceled event clears current tool and error state", - setup: func(app *App) { - app.state.IsAgentRunning = true - app.state.CurrentTool = "filesystem_edit" - app.state.ExecutionError = "old" - }, - event: agentruntime.RuntimeEvent{Type: agentruntime.EventRunCanceled, SessionID: "s1"}, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.IsAgentRunning || app.state.CurrentTool != "" || app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { - t.Fatalf("unexpected canceled state: %+v", app.state) - } - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - if tt.setup != nil { - tt.setup(&app) - } - app.handleRuntimeEvent(tt.event) - tt.assert(t, app) - }) - } -} - -func TestAppRefreshErrorPaths(t *testing.T) { - t.Run("refresh sessions returns runtime error", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - runtime.listErr = context.DeadlineExceeded - - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err == nil || !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { - t.Fatalf("expected list session error during New, got %v", err) - } - _ = app - }) - - t.Run("refresh messages returns load error", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - runtime.loadErr = context.Canceled - - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.state.ActiveSessionID = "broken" - - err = app.refreshMessages() - if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { - t.Fatalf("expected load session error, got %v", err) - } - }) -} - -func TestImmediateSlashCommandsAndLayoutBranches(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - handled, cmd := app.handleImmediateSlashCommand("/help") - if handled || cmd != nil { - t.Fatalf("expected /help to stay on normal slash flow") - } - - handled, cmd = app.handleImmediateSlashCommand("/clear") - if !handled || cmd != nil { - t.Fatalf("expected /clear to be handled locally") - } - if app.state.ActiveSessionID != "" || len(app.activeMessages) != 0 { - t.Fatalf("expected /clear to reset draft state") - } - - handled, cmd = app.handleImmediateSlashCommand("/compact") - if !handled || cmd != nil { - t.Fatalf("expected /compact without session to be handled locally") - } - if !strings.Contains(app.state.StatusText, "compact requires an existing session") { - t.Fatalf("expected missing-session compact hint, got %q", app.state.StatusText) - } - if len(runtime.compactInputs) != 0 { - t.Fatalf("expected no runtime compact call without session, got %+v", runtime.compactInputs) - } - - runtime.compactResult = agentruntime.CompactResult{ - Applied: true, - BeforeChars: 100, - AfterChars: 40, - SavedRatio: 0.6, - TriggerMode: string(contextcompact.ModeManual), - TranscriptPath: "/tmp/transcript.jsonl", - } - app.state.ActiveSessionID = "session-compact" - handled, cmd = app.handleImmediateSlashCommand("/compact") - if !handled || cmd == nil { - t.Fatalf("expected /compact to trigger compact cmd") - } - if app.state.StatusText != statusCompacting { - t.Fatalf("expected compact status %q, got %q", statusCompacting, app.state.StatusText) - } - if !app.state.IsCompacting { - t.Fatalf("expected /compact to mark UI as compacting") - } - msgs := collectTeaMessages(cmd) - if len(msgs) != 1 { - t.Fatalf("expected one compact command message, got %d", len(msgs)) - } - if _, ok := msgs[0].(compactFinishedMsg); !ok { - t.Fatalf("expected compact finished msg, got %T", msgs[0]) - } - if len(runtime.compactInputs) != 1 || runtime.compactInputs[0].SessionID != "session-compact" { - t.Fatalf("expected runtime compact call with active session, got %+v", runtime.compactInputs) - } - - handled, cmd = app.handleImmediateSlashCommand("/compact") - if !handled || cmd != nil { - t.Fatalf("expected re-entrant /compact to be rejected while compacting") - } - if !strings.Contains(strings.ToLower(app.state.StatusText), "compact is already running") { - t.Fatalf("expected compact busy hint, got %q", app.state.StatusText) - } - - handled, cmd = app.handleImmediateSlashCommand("/compact now") - if !handled || cmd != nil { - t.Fatalf("expected /compact with args to be handled locally with usage error") - } - if !strings.Contains(app.state.StatusText, "usage: /compact") { - t.Fatalf("expected /compact usage hint, got %q", app.state.StatusText) - } - - handled, cmd = app.handleImmediateSlashCommand("/exit") - if !handled || cmd == nil { - t.Fatalf("expected /exit to return a quit cmd") - } - foundQuit := false - for _, msg := range collectTeaMessages(cmd) { - if _, ok := msg.(tea.QuitMsg); ok { - foundQuit = true - } - } - if !foundQuit { - t.Fatalf("expected quit msg from /exit") - } - - app.state.IsAgentRunning = false - app.transcript.Width = 40 - app.transcript.Height = 4 - app.transcript.SetContent(strings.Repeat("line\n", 20)) - app.transcript.GotoBottom() - app.applyComponentLayout(false) - if app.transcript.Width <= 0 || app.transcript.Height <= 0 { - t.Fatalf("expected resizeComposerLayout to keep transcript dimensions positive") - } - - snapshot := app.currentStatusSnapshot() - if snapshot.FocusLabel == "" || snapshot.CurrentProvider == "" || snapshot.CurrentModel == "" { - t.Fatalf("expected non-empty status snapshot fields, got %+v", snapshot) - } -} - -func TestCompactEventAndBusyBranches(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.state.ActiveSessionID = "s1" - dirty := app.handleRuntimeEvent(agentruntime.RuntimeEvent{ - Type: agentruntime.EventCompactDone, - SessionID: "s1", - Payload: agentruntime.CompactDonePayload{ - Applied: true, - BeforeChars: 100, - AfterChars: 60, - SavedRatio: 0.4, - TriggerMode: string(contextcompact.ModeManual), - TranscriptPath: "/tmp/t.jsonl", - }, - }) - if !dirty { - t.Fatalf("expected compact done to dirty transcript") - } - if !strings.Contains(app.state.StatusText, "Compact(manual)") { - t.Fatalf("expected compact status text, got %q", app.state.StatusText) - } - if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "Compact(manual)") { - t.Fatalf("expected compact inline notice, got %+v", app.activeMessages) - } - - dirty = app.handleRuntimeEvent(agentruntime.RuntimeEvent{ - Type: agentruntime.EventCompactError, - SessionID: "s1", - Payload: agentruntime.CompactErrorPayload{ - TriggerMode: string(contextcompact.ModeManual), - Message: "disk full", - }, - }) - if !dirty { - t.Fatalf("expected compact error to dirty transcript") - } - if !strings.Contains(app.state.ExecutionError, "Compact(manual) failed: disk full") { - t.Fatalf("expected compact error in state, got %q", app.state.ExecutionError) - } - - app.state.IsCompacting = true - app.state.ActiveSessionID = "session-existing" - app.state.ActiveSessionTitle = "Existing" - app.input.SetValue("hello") - app.state.InputText = "hello" - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - if len(runtime.runInputs) != 0 { - t.Fatalf("expected send to be blocked while compacting, got %+v", runtime.runInputs) - } - if app.state.InputText != "hello" { - t.Fatalf("expected input unchanged while compacting, got %q", app.state.InputText) - } - - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.state.ActiveSessionID != "session-existing" { - t.Fatalf("expected new-session shortcut to be blocked while compacting") - } - - model, cmd = app.Update(compactFinishedMsg{err: nil}) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.state.IsCompacting { - t.Fatalf("expected compact finished message to clear busy compacting state") - } -} - -func TestAdditionalRenderingAndToolChunkBranches(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.state.ActiveSessionID = "session-tool" - app.handleRuntimeEvent(agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolChunk, - SessionID: "session-tool", - Payload: "chunk output", - }) - if app.state.StatusText != statusRunningTool { - t.Fatalf("expected tool chunk to keep running status, got %q", app.state.StatusText) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected tool chunk to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || !strings.Contains(app.activities[len(app.activities)-1].Detail, "chunk output") { - t.Fatalf("expected tool chunk preview in activity, got %+v", app.activities) - } - - if got := wrapCodeBlock("a\tb", 3); !strings.Contains(got, "\n") { - t.Fatalf("expected tabs to expand and wrap, got %q", got) - } - if got := wrapCodeBlock("abc", 0); got != "abc" { - t.Fatalf("expected width<=0 to return original text, got %q", got) - } - - rendered, _ := app.renderMessageContentWithCopy("```\n```", 20, app.styles.messageBody, 1) - if !strings.Contains(stripANSI(rendered), emptyMessageText) { - t.Fatalf("expected empty code block placeholder, got %q", rendered) - } -} - -func TestHandleViewportKeysPageScrollingUsesFullPage(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.transcript.SetContent(strings.Repeat("line\n", 120)) - app.transcript.Height = 10 - app.transcript.GotoTop() - - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyPgDown}) - if app.transcript.YOffset != 10 { - t.Fatalf("expected page down to move a full page, got offset %d", app.transcript.YOffset) - } - - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyPgUp}) - if app.transcript.YOffset != 0 { - t.Fatalf("expected page up to return a full page, got offset %d", app.transcript.YOffset) - } - - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyEnd}) - if !app.transcript.AtBottom() { - t.Fatalf("expected end to jump to bottom") - } - - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyHome}) - if !app.transcript.AtTop() { - t.Fatalf("expected home to jump to top") - } -} - -func TestTranscriptMouseWheelScrollsOnlyInsideTranscript(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.width = 128 - app.height = 40 - app.applyComponentLayout(true) - app.transcript.SetContent(strings.Repeat("line\n", 160)) - app.transcript.GotoTop() - - x, y, _, _ := app.transcriptBounds() - model, cmd := app.Update(tea.MouseMsg{ - X: x + 1, - Y: y + 1, - Button: tea.MouseButtonWheelDown, - Type: tea.MouseWheelDown, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.transcript.YOffset != mouseWheelStepLines { - t.Fatalf("expected wheel down to scroll transcript by %d lines, got %d", mouseWheelStepLines, app.transcript.YOffset) - } - - model, cmd = app.Update(tea.MouseMsg{ - X: x + 1, - Y: y + 1, - Button: tea.MouseButtonWheelUp, - Type: tea.MouseWheelUp, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.transcript.YOffset != 0 { - t.Fatalf("expected wheel up to scroll transcript back to top, got %d", app.transcript.YOffset) - } - - model, cmd = app.Update(tea.MouseMsg{ - X: 0, - Y: 0, - Button: tea.MouseButtonWheelDown, - Type: tea.MouseWheelDown, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.transcript.YOffset != 0 { - t.Fatalf("expected wheel event outside transcript to be ignored, got %d", app.transcript.YOffset) - } - - app.transcript.Height = 0 - if app.isMouseWithinTranscript(tea.MouseMsg{X: x + 1, Y: y + 1}) { - t.Fatalf("expected zero-height transcript bounds to reject mouse hits") - } - - app.transcript.Height = 10 - if app.handleTranscriptMouse(tea.MouseMsg{X: x + 1, Y: y + 1, Button: tea.MouseButtonLeft}) { - t.Fatalf("expected non-wheel mouse button to be ignored") - } - - app.width = 100 - app.height = 32 - app.applyComponentLayout(true) - stackX, stackY, _, stackH := app.transcriptBounds() - if stackH <= 0 || !app.isMouseWithinTranscript(tea.MouseMsg{X: stackX + 1, Y: stackY + 1}) { - t.Fatalf("expected stacked layout transcript bounds to accept mouse hits") - } -} - -func TestInputMouseWheelScrollsComposer(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.width = 128 - app.height = 40 - app.input.SetValue(strings.Join([]string{ - "line1", "line2", "line3", "line4", "line5", - "line6", "line7", "line8", "line9", "line10", - "line11", "line12", - }, "\n")) - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - app.focus = panelTranscript - app.applyFocus() - - initialLine := app.input.Line() - if initialLine < 1 { - t.Fatalf("expected cursor line to be >=1 for multiline input, got %d", initialLine) - } - pageStep := max(1, app.input.Height()-1) - - x, y, _, _ := app.inputBounds() - model, cmd := app.Update(tea.MouseMsg{ - X: x + 1, - Y: y + 1, - Button: tea.MouseButtonWheelUp, - Type: tea.MouseWheelUp, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.focus != panelInput { - t.Fatalf("expected input wheel to focus input panel, got %v", app.focus) - } - if initialLine-app.input.Line() < pageStep-1 { - t.Fatalf("expected wheel up in input to page-scroll by ~%d lines, got from %d to %d", pageStep, initialLine, app.input.Line()) - } - - lineAfterUp := app.input.Line() - model, cmd = app.Update(tea.MouseMsg{ - X: x + 1, - Y: y + 1, - Button: tea.MouseButtonWheelDown, - Type: tea.MouseWheelDown, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.input.Line()-lineAfterUp < pageStep-1 { - t.Fatalf("expected wheel down in input to page-scroll by ~%d lines, got from %d to %d", pageStep, lineAfterUp, app.input.Line()) - } - - lineBeforeOutside := app.input.Line() - model, cmd = app.Update(tea.MouseMsg{ - X: 0, - Y: 0, - Button: tea.MouseButtonWheelUp, - Type: tea.MouseWheelUp, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.input.Line() != lineBeforeOutside { - t.Fatalf("expected wheel outside input to be ignored, got line=%d", app.input.Line()) - } -} - -func TestInputCharLimitIsUnlimited(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - if app.input.CharLimit != 0 { - t.Fatalf("expected unlimited input char limit, got %d", app.input.CharLimit) - } -} - -func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - if app.activityPreviewHeight() != 0 || app.renderActivityPreview(80) != "" { - t.Fatalf("expected empty activity state to render nothing") - } - - fixed := time.Date(2026, 4, 2, 9, 30, 0, 0, time.UTC) - app.activities = []activityEntry{ - {Time: fixed, Kind: "tool", Title: "first", Detail: "alpha"}, - {Time: fixed, Kind: "", Title: "second", Detail: ""}, - {Time: fixed, Kind: "provider", Title: "third", Detail: "retry"}, - {Time: fixed, Kind: "run", Title: "fourth", Detail: "done"}, - } - app.applyComponentLayout(true) - app.rebuildActivity() - app.focus = panelActivity - if app.focusLabel() != focusLabelActivity { - t.Fatalf("expected activity focus label, got %q", app.focusLabel()) - } - if app.activityPreviewHeight() != 6 { - t.Fatalf("expected fixed activity preview height, got %d", app.activityPreviewHeight()) - } - - preview := app.renderActivityPreview(64) - if !strings.Contains(preview, "second") || !strings.Contains(preview, "third") || !strings.Contains(preview, "fourth") { - t.Fatalf("expected latest activity rows in preview, got %q", preview) - } - if strings.Contains(preview, "first") { - t.Fatalf("expected oldest activity row to be clipped from current viewport, got %q", preview) - } - - line := app.renderActivityLine(activityEntry{Time: fixed, Kind: "", Title: "single line", Detail: ""}, 80) - if !strings.Contains(line, "EVENT") || strings.Contains(line, "single line:") { - t.Fatalf("expected fallback kind without detail suffix, got %q", line) - } - - rendered, _ := app.renderMessageContentWithCopy("before\n```go\nfmt.Println(1)\n```\nafter", 30, app.styles.messageBody, 1) - rendered = stripANSI(rendered) - if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(") || !strings.Contains(rendered, "1)") || !strings.Contains(rendered, "after") { - t.Fatalf("expected mixed prose and code to render, got %q", rendered) - } - if !strings.Contains(rendered, "[Copy code #1]") { - t.Fatalf("expected copy button alongside code block, got %q", rendered) - } - - if got := compactStatusText("\n hello world \n", 0); got != "hello world" { - t.Fatalf("expected compact status without truncation, got %q", got) - } - if got := compactStatusText("\n \n", 10); got != "" { - t.Fatalf("expected empty compact status for blank input, got %q", got) - } - - if app.statusBadge("failed request") == "" || app.statusBadge("canceled") == "" || app.statusBadge("ready") == "" { - t.Fatalf("expected status badge branches to render non-empty output") - } -} - -func TestRenderMessageContentUsesMarkdownRenderer(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - stub := &stubMarkdownRenderer{output: "markdown-rendered"} - app.markdownRenderer = stub - - rendered, _ := app.renderMessageContentWithCopy("# Title\n\n- item", 40, app.styles.messageBody, 1) - if !strings.Contains(rendered, "markdown-rendered") { - t.Fatalf("expected markdown renderer output, got %q", rendered) - } - if stub.calls != 1 { - t.Fatalf("expected markdown renderer to be called once, got %d", stub.calls) - } -} - -func TestRenderMessageBlockUserContentAlignsWithUserTag(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - renderedMessage, _ := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleUser, Content: "nihao"}, 80, 1) - rendered := stripANSI(renderedMessage) - lines := strings.Split(rendered, "\n") - - tagLine := "" - bodyLine := "" - for _, line := range lines { - if strings.Contains(line, messageTagUser) { - tagLine = line - } - if strings.Contains(line, "nihao") { - bodyLine = line - } - } - if tagLine == "" || bodyLine == "" { - t.Fatalf("expected user tag and body lines, got %q", rendered) - } - - tagCol := strings.Index(tagLine, messageTagUser) - bodyCol := strings.Index(bodyLine, "nihao") - if tagCol < 0 || bodyCol < 0 { - t.Fatalf("expected valid columns for user tag/body, got tag=%d body=%d", tagCol, bodyCol) - } - if bodyCol+6 < tagCol { - t.Fatalf("expected user body to align near user tag, got tagCol=%d bodyCol=%d rendered=%q", tagCol, bodyCol, rendered) - } -} - -func TestRenderMessageContentNormalizesRightEdge(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.markdownRenderer = &stubMarkdownRenderer{ - output: "very long line\nshort\nmid", - } - - renderedRaw, _ := app.renderMessageContentWithCopy("ignored", 40, app.styles.messageBody, 1) - rendered := stripANSI(renderedRaw) - lines := strings.Split(rendered, "\n") - if len(lines) < 3 { - t.Fatalf("expected multiline output, got %q", rendered) - } - - firstWidth := len([]rune(lines[0])) - for i, line := range lines[1:] { - if len([]rune(line)) != firstWidth { - t.Fatalf("expected aligned right edge, line %d width=%d first=%d rendered=%q", i+1, len([]rune(line)), firstWidth, rendered) - } - } -} - -func TestRenderMessageContentShowsPlaceholderWhenMarkdownFails(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.markdownRenderer = &stubMarkdownRenderer{err: errors.New("render failed")} - - content := "before\n```go\nfmt.Println(1)\n```\nafter" - rendered, _ := app.renderMessageContentWithCopy(content, 50, app.styles.messageBody, 1) - if !strings.Contains(rendered, "fmt.Println(1)") { - t.Fatalf("expected code block to keep rendering when markdown prose fails, got %q", rendered) - } - if !strings.Contains(rendered, "[Copy code #1]") { - t.Fatalf("expected copy button for rendered code block, got %q", rendered) - } -} - -func TestRenderMessageContentShowsPlaceholderWhenRendererMissing(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.markdownRenderer = nil - - rendered, _ := app.renderMessageContentWithCopy("content", 50, app.styles.messageBody, 1) - if !strings.Contains(rendered, emptyMessageText) { - t.Fatalf("expected placeholder when markdown renderer is missing, got %q", rendered) - } -} - -func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { - previousExecutor := workspaceCommandExecutor - t.Cleanup(func() { workspaceCommandExecutor = previousExecutor }) - workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { - return "stubbed output for " + command, nil - } - - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.input.SetValue("& git status") - app.state.InputText = app.input.Value() - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - for _, msg := range collectTeaMessages(cmd) { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected & command not to hit agent runtime, got %+v", runtime.runInputs) - } - if app.state.StatusText != statusCommandDone { - t.Fatalf("expected command done status, got %q", app.state.StatusText) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected workspace command flow to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) < 2 { - t.Fatalf("expected running event and command result in activity, got %+v", app.activities) - } - first := app.activities[0] - if first.Title != "Running command" || !strings.Contains(first.Detail, "git status") { - t.Fatalf("expected running command activity, got %+v", first) - } - last := app.activities[len(app.activities)-1] - if last.Title != "Command finished" || !strings.Contains(last.Detail, "Command: & git status") || !strings.Contains(last.Detail, "stubbed output for git status") { - t.Fatalf("expected command output in activity, got %+v", last) - } - - app.fileCandidates = []string{"README.md", "internal/tui/update.go", "internal/tui/view.go"} - app.input.SetValue("inspect @internal/tui/upd") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - menu := app.renderCommandMenu(80) - if !strings.Contains(menu, fileMenuTitle) || !strings.Contains(menu, "@internal/tui/update.go") { - t.Fatalf("expected file suggestion menu, got %q", menu) - } - if strings.Count(menu, "\n") > 6 { - t.Fatalf("expected compact file suggestion menu, got %q", menu) - } - - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyTab}) - app = model.(App) - if cmd != nil { - _ = collectTeaMessages(cmd) - } - if app.focus != panelInput { - t.Fatalf("expected tab completion to keep focus in input, got %v", app.focus) - } - if app.state.InputText != "inspect @internal/tui/update.go" { - t.Fatalf("expected @ suggestion to be applied, got %q", app.state.InputText) - } - - app.input.SetValue("& go test ./...") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - menu = app.renderCommandMenu(80) - if !strings.Contains(menu, shellMenuTitle) || !strings.Contains(menu, workspaceCommandUsage) { - t.Fatalf("expected shell hint menu, got %q", menu) - } - // Shell menu should stay reasonably compact (title + one item row + padding). - // Allow extra newlines on Windows where long paths with non-ASCII characters - // may cause lipgloss to wrap the description line. - maxShellMenuLines := 4 - if goruntime.GOOS == "windows" { - maxShellMenuLines = 6 - } - if strings.Count(menu, "\n") > maxShellMenuLines { - t.Fatalf("expected compact shell menu, got %q", menu) - } -} - -func TestActivityMouseFilePickerAndProgressRendering(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.width = 120 - app.height = 38 - app.applyComponentLayout(true) - x0, y0, _, _ := app.activityBounds() - if app.isMouseWithinActivity(tea.MouseMsg{X: x0, Y: y0}) { - t.Fatalf("expected empty activity panel hit test to be false") - } - if app.handleActivityMouse(tea.MouseMsg{X: x0, Y: y0, Type: tea.MouseWheelDown, Button: tea.MouseButtonWheelDown}) { - t.Fatalf("expected mouse handling to be false without activities") - } - - app.appendActivity("tool", "Running tool", "detail", false) - app.applyComponentLayout(true) - ax, ay, aw, ah := app.activityBounds() - if aw <= 0 || ah <= 0 { - t.Fatalf("expected visible activity bounds, got width=%d height=%d", aw, ah) - } - inside := tea.MouseMsg{ - X: ax + 1, - Y: ay + 1, - Type: tea.MouseWheelDown, - Button: tea.MouseButtonWheelDown, - Action: tea.MouseActionPress, - } - app.focus = panelTranscript - if !app.handleActivityMouse(inside) { - t.Fatalf("expected activity wheel event to be handled") - } - if app.focus != panelActivity { - t.Fatalf("expected focus to move to activity panel, got %v", app.focus) - } - - app.state.ActivePicker = pickerModel - if app.handleActivityMouse(inside) { - t.Fatalf("expected activity mouse ignored when picker is active") - } - app.state.ActivePicker = pickerNone - if app.isMouseWithinActivity(tea.MouseMsg{X: ax + aw + 2, Y: ay}) { - t.Fatalf("expected out-of-bound mouse point to be rejected") - } - - app.state.ActivePicker = pickerFile - if pickerView := stripANSI(app.renderPicker(48, 12)); !strings.Contains(pickerView, filePickerTitle) { - t.Fatalf("expected file picker title, got %q", pickerView) - } - model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyDown}) - app = model.(App) - if cmd != nil { - _ = collectTeaMessages(cmd) - } - - app.state.IsAgentRunning = true - app.state.StatusText = "thinking" - app.runProgressKnown = true - app.runProgressValue = 0.45 - app.runProgressLabel = "Planning" - header := stripANSI(app.renderHeader(100)) - if !strings.Contains(header, "Planning") { - t.Fatalf("expected progress label in header, got %q", header) - } - app.runProgressLabel = "" - app.state.StatusText = "" - header = stripANSI(app.renderHeader(100)) - if !strings.Contains(header, statusRunning) { - t.Fatalf("expected running fallback text in header, got %q", header) - } - - app.state.ActivePicker = pickerFile - snapshot := app.currentStatusSnapshot() - if snapshot.PickerLabel != "file" { - t.Fatalf("expected picker label file, got %q", snapshot.PickerLabel) - } - - app.runProgressKnown = true - modelAny, _ := app.Update(RuntimeClosedMsg{}) - closed := modelAny.(App) - if closed.runProgressKnown { - t.Fatalf("expected RuntimeClosedMsg to clear run progress") - } -} - -func newTestConfigManager(t *testing.T) *config.Manager { - t.Helper() - manager := config.NewManager(config.NewLoader(t.TempDir(), config.DefaultConfig())) - if _, err := manager.Load(context.Background()); err != nil { - t.Fatalf("load config: %v", err) - } - return manager -} - -func newTestProviderService(t *testing.T, manager *config.Manager) *config.SelectionService { - t.Helper() - - registry := provider.NewRegistry() - err := registry.Register(provider.DriverDefinition{ - Name: config.OpenAIName, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { - return tUItestProvider{}, nil - }, - }) - if err != nil { - t.Fatalf("register provider drivers: %v", err) - } - modelCatalogs := providercatalog.NewService("", registry, newTUItestCatalogStore()) - return config.NewSelectionService(manager, registry, registry, modelCatalogs) -} - -type tUItestProvider struct{} - -func (tUItestProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { - return nil -} - -type tUItestCatalogStore struct { - catalogs map[string]providercatalog.ModelCatalog - mu sync.Mutex -} - -func newTUItestCatalogStore() *tUItestCatalogStore { - return &tUItestCatalogStore{ - catalogs: map[string]providercatalog.ModelCatalog{}, - } -} - -func (s *tUItestCatalogStore) Load(ctx context.Context, identity config.ProviderIdentity) (providercatalog.ModelCatalog, error) { - if err := ctx.Err(); err != nil { - return providercatalog.ModelCatalog{}, err - } - - catalog, ok := s.catalogs[identity.Key()] - if !ok { - return providercatalog.ModelCatalog{}, providercatalog.ErrCatalogNotFound - } - return catalog, nil -} - -func (s *tUItestCatalogStore) Save(ctx context.Context, catalog providercatalog.ModelCatalog) error { - if err := ctx.Err(); err != nil { - return err - } - - s.catalogs[catalog.Identity.Key()] = catalog - return nil -} - -func collectTeaMessages(cmd tea.Cmd) []tea.Msg { - if cmd == nil { - return nil - } - msgCh := make(chan tea.Msg, 1) - go func() { - msgCh <- cmd() - }() - - var msg tea.Msg - select { - case msg = <-msgCh: - case <-time.After(250 * time.Millisecond): - return nil - } - if msg == nil { - return nil - } - switch typed := msg.(type) { - case tea.BatchMsg: - var out []tea.Msg - for _, child := range typed { - out = append(out, collectTeaMessages(child)...) - } - return out - default: - return []tea.Msg{typed} - } -} - -func stripANSI(input string) string { - return ansiPattern.ReplaceAllString(input, "") -}