From b7c186a9c703f10f6cf2351d0721359ba5739fef Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 1 Apr 2026 18:31:55 +0800 Subject: [PATCH 1/4] feat: expand slash commands in the tui --- internal/tui/commands.go | 486 ++++++++++++++++++++++++++++++++-- internal/tui/commands_test.go | 172 +++++++++++- internal/tui/keymap.go | 32 +-- internal/tui/styles.go | 23 ++ internal/tui/update.go | 98 +++++-- internal/tui/view.go | 7 +- 6 files changed, 759 insertions(+), 59 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 4aacf6a1..d29e3244 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -2,21 +2,52 @@ package tui import ( "context" + "errors" "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" "strings" + "time" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "neo-code/internal/config" "neo-code/internal/provider" ) const ( - slashPrefix = "/" - slashCommandProviderPick = "/provider" - slashCommandModelPicker = "/model" - + slashPrefix = "/" + slashCommandHelp = "/help" + slashCommandExit = "/exit" + slashCommandClear = "/clear" + slashCommandStatus = "/status" + slashCommandRun = "/run" + slashCommandGit = "/git" + slashCommandFile = "/file" + slashCommandPlan = "/plan" + slashCommandUndo = "/undo" + slashCommandProvider = "/provider" + slashCommandSetting = "/setting" + slashCommandSet = "/set" + slashCommandModelPick = "/model" + + slashUsageHelp = "/help" + slashUsageExit = "/exit" + slashUsageClear = "/clear" + slashUsageStatus = "/status" + slashUsageRun = "/run " + slashUsageGit = "/git " + slashUsageFile = "/file ..." + slashUsagePlan = "/plan" + slashUsageUndo = "/undo" slashUsageProvider = "/provider" + slashUsageSetting = "/setting [provider|model|workdir]" + slashUsageSetURL = "/set url " + slashUsageSetKey = "/set key " slashUsageModel = "/model" commandMenuTitle = "Commands" @@ -30,22 +61,23 @@ const ( sidebarOpenHint = "Enter to open" draftSessionTitle = "Draft" - emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type / to browse local commands." + emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type /help to browse local commands." emptyMessageText = "(empty)" - statusReady = "Ready" - statusRuntimeClosed = "Runtime closed" - statusThinking = "Thinking" - statusCanceling = "Canceling" - statusCanceled = "Canceled" - statusRunningTool = "Running tool" - statusToolFinished = "Tool finished" - statusToolError = "Tool error" - statusError = "Error" - statusDraft = "New draft" - statusRunning = "Running" - statusChooseProvider = "Choose a provider" - statusChooseModel = "Choose a model" + statusReady = "Ready" + statusRuntimeClosed = "Runtime closed" + statusThinking = "Thinking" + statusCanceling = "Canceling" + statusCanceled = "Canceled" + statusRunningTool = "Running tool" + statusToolFinished = "Tool finished" + statusToolError = "Tool error" + statusError = "Error" + statusDraft = "New draft" + statusRunning = "Running" + statusApplyingCommand = "Applying local command" + statusChooseProvider = "Choose a provider" + statusChooseModel = "Choose a model" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" @@ -74,10 +106,24 @@ type commandSuggestion struct { } var builtinSlashCommands = []slashCommand{ + {Usage: slashUsageHelp, Description: "Show slash command help"}, + {Usage: slashUsageClear, Description: "Clear the current draft transcript"}, + {Usage: slashUsageStatus, Description: "Show workspace and Git status"}, + {Usage: slashUsageRun, Description: "Run a shell command inside the workspace"}, + {Usage: slashUsageGit, Description: "Run a Git command in the workspace"}, + {Usage: slashUsageFile, Description: "Read, write, or list workspace files"}, + {Usage: slashUsagePlan, Description: "Generate a local task plan"}, + {Usage: slashUsageUndo, Description: "Undo the last local transcript entry"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, + {Usage: slashUsageSetting, Description: "Show or change local settings"}, + {Usage: slashUsageSetURL, Description: "Set the API Base URL"}, + {Usage: slashUsageSetKey, Description: "Update the API key"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, + {Usage: slashUsageExit, Description: "Exit NeoCode"}, } +var shellCommandExecutor = defaultShellCommandExecutor + func newSelectionPicker(items []list.Item) list.Model { delegate := list.NewDefaultDelegate() picker := list.New(items, delegate, 0, 0) @@ -228,3 +274,407 @@ func runModelSelection(providerSvc ProviderController, modelID string) tea.Cmd { } } } + +func runLocalCommand(configManager *config.Manager, providerSvc ProviderController, raw string) tea.Cmd { + return func() tea.Msg { + notice, err := executeLocalCommand(context.Background(), configManager, providerSvc, raw) + return localCommandResultMsg{notice: notice, err: err} + } +} + +func executeLocalCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, raw string) (string, error) { + fields := strings.Fields(strings.TrimSpace(raw)) + if len(fields) == 0 { + return "", fmt.Errorf("empty command") + } + + switch strings.ToLower(fields[0]) { + case slashCommandHelp: + return slashHelpText(), nil + case slashCommandStatus: + return executeStatusCommand(ctx, configManager) + case slashCommandRun: + return executeRunCommand(ctx, configManager, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandRun))) + case slashCommandGit: + return executeGitCommand(ctx, configManager, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandGit))) + case slashCommandFile: + return executeFileCommand(ctx, configManager, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandFile))) + case slashCommandPlan: + return buildGenericPlanNotice(), nil + case slashCommandProvider: + return executeProviderCommand(ctx, providerSvc, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandProvider))) + case slashCommandSetting: + return executeSettingCommand(ctx, configManager, providerSvc, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandSetting))) + case slashCommandSet: + return executeSetCommand(ctx, configManager, providerSvc, fields) + default: + return "", fmt.Errorf("unknown command %q", fields[0]) + } +} + +func executeSetCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, fields []string) (string, error) { + if len(fields) < 3 { + return "", fmt.Errorf("usage: %s | %s | %s", slashUsageSetURL, slashUsageSetKey, slashUsageModel) + } + + value := strings.TrimSpace(strings.Join(fields[2:], " ")) + if value == "" { + return "", fmt.Errorf("command value is empty") + } + + switch strings.ToLower(fields[1]) { + case "url": + if _, err := url.ParseRequestURI(value); err != nil { + return "", fmt.Errorf("invalid url: %w", err) + } + if err := configManager.Update(ctx, func(cfg *config.Config) error { + selectedName := strings.TrimSpace(cfg.SelectedProvider) + for i := range cfg.Providers { + if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), selectedName) { + cfg.Providers[i].BaseURL = value + return nil + } + } + return fmt.Errorf("selected provider %q not found", cfg.SelectedProvider) + }); err != nil { + return "", err + } + cfg := configManager.Get() + return fmt.Sprintf("[System] Base URL updated for %s -> %s", cfg.SelectedProvider, value), nil + case "key": + cfg := configManager.Get() + selected, err := cfg.SelectedProviderConfig() + if err != nil { + return "", err + } + if err := os.Setenv(selected.APIKeyEnv, value); err != nil { + return "", fmt.Errorf("set api key env: %w", err) + } + return fmt.Sprintf("[System] %s updated for the current process.", selected.APIKeyEnv), nil + case "model": + selection, err := providerSvc.SetCurrentModel(ctx, value) + if err != nil { + return "", err + } + return fmt.Sprintf("[System] Current model switched to %s.", selection.ModelID), nil + default: + return "", fmt.Errorf("unsupported /set field %q", fields[1]) + } +} + +func executeStatusCommand(ctx context.Context, configManager *config.Manager) (string, error) { + cfg := configManager.Get() + lines := []string{ + "Workspace status:", + "Workdir: " + cfg.Workdir, + "Provider: " + cfg.SelectedProvider, + "Model: " + cfg.CurrentModel, + "Config: " + configManager.ConfigPath(), + } + + gitStatus, err := shellCommandExecutor(ctx, cfg, "git status --short --branch") + if err != nil { + lines = append(lines, "Git: unavailable ("+err.Error()+")") + } else { + lines = append(lines, "Git:") + lines = append(lines, indentBlock(gitStatus, " ")) + } + + return strings.Join(lines, "\n"), nil +} + +func executeRunCommand(ctx context.Context, configManager *config.Manager, command string) (string, error) { + command = strings.TrimSpace(command) + if command == "" { + return "", fmt.Errorf("usage: %s", slashUsageRun) + } + + cfg := configManager.Get() + output, err := shellCommandExecutor(ctx, cfg, command) + if err != nil { + return "", err + } + return "Run output:\n" + indentBlock(output, " "), nil +} + +func executeGitCommand(ctx context.Context, configManager *config.Manager, args string) (string, error) { + args = strings.TrimSpace(args) + if args == "" { + return "", fmt.Errorf("usage: %s", slashUsageGit) + } + + cfg := configManager.Get() + output, err := shellCommandExecutor(ctx, cfg, "git "+args) + if err != nil { + return "", err + } + return "Git output:\n" + indentBlock(output, " "), nil +} + +func executeFileCommand(ctx context.Context, configManager *config.Manager, args string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + cfg := configManager.Get() + subcommand, remainder := splitFirstWord(args) + switch strings.ToLower(subcommand) { + case "read": + path, _ := splitFirstWord(remainder) + if strings.TrimSpace(path) == "" { + return "", fmt.Errorf("usage: %s", slashUsageFile) + } + target, err := resolveWorkspacePath(cfg.Workdir, path) + if err != nil { + return "", err + } + data, err := os.ReadFile(target) + if err != nil { + return "", err + } + return fmt.Sprintf("File: %s\n%s", target, string(data)), nil + case "write": + path, content := splitFirstWord(remainder) + if strings.TrimSpace(path) == "" || strings.TrimSpace(content) == "" { + return "", fmt.Errorf("usage: /file write ") + } + target, err := resolveWorkspacePath(cfg.Workdir, path) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(target, []byte(content), 0o644); err != nil { + return "", err + } + return fmt.Sprintf("Wrote %d bytes to %s", len(content), target), nil + case "list": + path := strings.TrimSpace(remainder) + if path == "" { + path = "." + } + target, err := resolveWorkspacePath(cfg.Workdir, path) + if err != nil { + return "", err + } + info, err := os.Stat(target) + if err != nil { + return "", err + } + if !info.IsDir() { + return fmt.Sprintf("File: %s (%d bytes)", target, info.Size()), nil + } + entries, err := os.ReadDir(target) + if err != nil { + return "", err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + name += string(os.PathSeparator) + } + names = append(names, name) + } + sort.Strings(names) + if len(names) == 0 { + return fmt.Sprintf("Directory %s is empty", target), nil + } + return fmt.Sprintf("Files in %s:\n%s", target, indentBlock(strings.Join(names, "\n"), " ")), nil + default: + return "", fmt.Errorf("usage: %s", slashUsageFile) + } +} + +func executeSettingCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, args string) (string, error) { + cfg := configManager.Get() + subcommand, remainder := splitFirstWord(args) + if subcommand == "" { + return strings.Join([]string{ + "Settings:", + "Provider: " + cfg.SelectedProvider, + "Model: " + cfg.CurrentModel, + "Workdir: " + cfg.Workdir, + "Shell: " + cfg.Shell, + "Config: " + configManager.ConfigPath(), + }, "\n"), nil + } + + value := strings.TrimSpace(remainder) + if value == "" { + return "", fmt.Errorf("usage: %s", slashUsageSetting) + } + + switch strings.ToLower(subcommand) { + case "model": + selection, err := providerSvc.SetCurrentModel(ctx, value) + if err != nil { + return "", err + } + return fmt.Sprintf("[System] Current model switched to %s.", selection.ModelID), nil + case "workdir": + next, err := resolveRequestedWorkdir(cfg.Workdir, value) + if err != nil { + return "", err + } + if err := configManager.Update(ctx, func(cfg *config.Config) error { + cfg.Workdir = next + return nil + }); err != nil { + return "", err + } + if _, err := configManager.Reload(ctx); err != nil { + return "", fmt.Errorf("reload config: %w", err) + } + return "[System] Workdir updated to " + next, nil + case "provider": + return executeProviderCommand(ctx, providerSvc, value) + default: + return "", fmt.Errorf("unsupported /setting field %q", subcommand) + } +} + +func executeProviderCommand(ctx context.Context, providerSvc ProviderController, value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("usage: %s", slashUsageProvider) + } + selection, err := providerSvc.SelectProvider(ctx, value) + if err != nil { + return "", err + } + return fmt.Sprintf("[System] Current provider switched to %s.", selection.ProviderID), nil +} + +func slashHelpText() string { + lines := []string{"Available slash commands:"} + for _, command := range builtinSlashCommands { + lines = append(lines, fmt.Sprintf("%s - %s", command.Usage, command.Description)) + } + return strings.Join(lines, "\n") +} + +func buildGenericPlanNotice() string { + return strings.Join([]string{ + "Suggested plan:", + "1. Inspect the relevant files and current state.", + "2. Confirm the smallest safe change set.", + "3. Implement the change.", + "4. Run verification and review the output.", + }, "\n") +} + +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:]) +} + +func indentBlock(text string, prefix string) string { + text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n") + if text == "" { + return prefix + "(no output)" + } + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = prefix + line + } + return strings.Join(lines, "\n") +} + +func defaultShellCommandExecutor(ctx context.Context, cfg config.Config, command string) (string, error) { + command = strings.TrimSpace(command) + if command == "" { + return "", errors.New("command is empty") + } + + 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 = cfg.Workdir + output, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(output)) + if runCtx.Err() == context.DeadlineExceeded { + if text == "" { + return "", fmt.Errorf("command timed out after %ds", timeoutSec) + } + return "", fmt.Errorf("command timed out after %ds\n%s", timeoutSec, text) + } + if err != nil { + if text == "" { + return "", err + } + return "", fmt.Errorf("%w\n%s", err, text) + } + if text == "" { + return "(no output)", nil + } + return text, nil +} + +func shellArgs(shell string, command string) []string { + switch strings.ToLower(strings.TrimSpace(shell)) { + case "powershell", "pwsh": + return []string{"powershell", "-NoProfile", "-Command", command} + case "bash": + return []string{"bash", "-lc", command} + case "sh": + return []string{"sh", "-lc", command} + default: + return []string{"powershell", "-NoProfile", "-Command", command} + } +} + +func resolveWorkspacePath(root string, requested string) (string, error) { + base, err := filepath.Abs(root) + if err != nil { + return "", err + } + + target := strings.TrimSpace(requested) + if target == "" { + return "", errors.New("path is empty") + } + if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + + target, err = filepath.Abs(target) + if err != nil { + return "", err + } + + rel, err := filepath.Rel(base, target) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errors.New("path escapes workspace root") + } + return target, nil +} + +func resolveRequestedWorkdir(current string, requested string) (string, error) { + requested = strings.TrimSpace(requested) + if requested == "" { + return "", errors.New("workdir is empty") + } + if filepath.IsAbs(requested) { + return filepath.Clean(requested), nil + } + return filepath.Abs(filepath.Join(current, requested)) +} diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index af472f74..aaad2318 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -1,6 +1,174 @@ package tui -import "testing" +import ( + "context" + "os" + "strings" + "testing" + + "neo-code/internal/config" +) + +func TestExecuteLocalCommand(t *testing.T) { + previousExecutor := shellCommandExecutor + t.Cleanup(func() { shellCommandExecutor = previousExecutor }) + shellCommandExecutor = func(ctx context.Context, cfg config.Config, command string) (string, error) { + return "stubbed: " + command, nil + } + + tests := []struct { + name string + command string + expectErr string + assert func(t *testing.T, manager *config.Manager, notice string) + }{ + { + name: "help lists slash commands", + command: "/help", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + if !strings.Contains(notice, slashUsageHelp) || !strings.Contains(notice, slashUsageExit) { + t.Fatalf("expected help output to list slash commands, got %q", notice) + } + }, + }, + { + name: "status includes workspace details", + command: "/status", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + if !strings.Contains(notice, "Workspace status:") || !strings.Contains(notice, "stubbed: git status --short --branch") { + t.Fatalf("expected status output, got %q", notice) + } + }, + }, + { + name: "run executes shell command", + command: "/run echo hi", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + if !strings.Contains(notice, "stubbed: echo hi") { + t.Fatalf("expected run output, got %q", notice) + } + }, + }, + { + name: "git executes git command", + command: "/git status", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + if !strings.Contains(notice, "stubbed: git status") { + t.Fatalf("expected git output, got %q", notice) + } + }, + }, + { + name: "provider switches current provider", + 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: "set url updates selected provider", + command: "/set url https://test.example/v1", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + cfg := manager.Get() + selected, err := cfg.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() error = %v", err) + } + if selected.BaseURL != "https://test.example/v1" { + t.Fatalf("expected updated base url, got %q", selected.BaseURL) + } + if !strings.Contains(notice, "Base URL updated") { + t.Fatalf("expected update notice, got %q", notice) + } + }, + }, + { + name: "set model updates current model", + command: "/set model gpt-4.1", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + cfg := manager.Get() + if cfg.CurrentModel != "gpt-4.1" { + t.Fatalf("expected current model gpt-4.1, got %q", cfg.CurrentModel) + } + if !strings.Contains(notice, "Current model switched") { + t.Fatalf("expected model switch notice, got %q", notice) + } + }, + }, + { + name: "set key updates process env", + command: "/set key secret-key", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + if got := strings.TrimSpace(os.Getenv(config.OpenAIDefaultAPIKeyEnv)); got != "secret-key" { + t.Fatalf("expected env to be reloaded, got %q", got) + } + if !strings.Contains(notice, "updated for the current process") { + t.Fatalf("expected key reload notice, got %q", notice) + } + }, + }, + { + name: "provider requires an argument", + command: "/provider", + expectErr: "usage:", + }, + { + name: "unknown command is rejected", + command: "/unknown", + expectErr: `unknown command "/unknown"`, + }, + { + name: "set usage requires enough arguments", + command: "/set url", + expectErr: "usage:", + }, + { + name: "invalid url is rejected", + command: "/set url not-a-url", + expectErr: "invalid url", + }, + { + 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, 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() @@ -21,7 +189,7 @@ func TestMatchingSlashCommands(t *testing.T) { name: "bare slash returns all commands", input: "/", expectCount: len(builtinSlashCommands), - expectUsage: slashUsageModel, + expectUsage: slashUsageHelp, }, { name: "prefix narrows suggestions", diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index ece5b54d..7c6b9df5 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -25,67 +25,67 @@ func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("Enter", "发送(输入框)"), + key.WithHelp("Enter", "Send"), ), Newline: key.NewBinding( key.WithKeys("ctrl+j"), - key.WithHelp("Ctrl+J", "换行(输入框)"), + key.WithHelp("Ctrl+J", "New line"), ), CancelAgent: key.NewBinding( key.WithKeys("ctrl+w"), - key.WithHelp("Ctrl+W", "中止"), + key.WithHelp("Ctrl+W", "Cancel"), ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), - key.WithHelp("Ctrl+N", "新会话"), + key.WithHelp("Ctrl+N", "New session"), ), NextPanel: key.NewBinding( key.WithKeys("tab"), - key.WithHelp("Tab", "下个面板"), + key.WithHelp("Tab", "Next panel"), ), PrevPanel: key.NewBinding( key.WithKeys("shift+tab"), - key.WithHelp("Shift+Tab", "上个面板"), + key.WithHelp("Shift+Tab", "Prev panel"), ), FocusInput: key.NewBinding( key.WithKeys("esc"), - key.WithHelp("Esc", "聚焦输入框"), + key.WithHelp("Esc", "Focus input"), ), OpenSession: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("Enter", "打开会话"), + key.WithHelp("Enter", "Open session"), ), ToggleHelp: key.NewBinding( key.WithKeys("ctrl+q"), - key.WithHelp("Ctrl+Q", "帮助"), + key.WithHelp("Ctrl+Q", "/help"), ), Quit: key.NewBinding( key.WithKeys("ctrl+u"), - key.WithHelp("Ctrl+U", "退出"), + key.WithHelp("Ctrl+U", "/exit"), ), ScrollUp: key.NewBinding( key.WithKeys("up", "k"), - key.WithHelp("Up/K", "向上滚动"), + key.WithHelp("Up/K", "Scroll up"), ), ScrollDown: key.NewBinding( key.WithKeys("down", "j"), - key.WithHelp("Down/J", "向下滚动"), + key.WithHelp("Down/J", "Scroll down"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), - key.WithHelp("PgUp/B", "向上翻页"), + key.WithHelp("PgUp/B", "Page up"), ), PageDown: key.NewBinding( key.WithKeys("pgdown", "f"), - key.WithHelp("PgDn/F", "向下翻页"), + key.WithHelp("PgDn/F", "Page down"), ), Top: key.NewBinding( key.WithKeys("g", "home"), - key.WithHelp("G/Home", "跳到顶部"), + key.WithHelp("G/Home", "Top"), ), Bottom: key.NewBinding( key.WithKeys("G", "end"), - key.WithHelp("Shift+G/End", "跳到底部"), + key.WithHelp("Shift+G/End", "Bottom"), ), } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index a0326dcb..82b69099 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -263,6 +263,29 @@ func wrapPlain(text string, width int) string { return strings.Join(out, "\n") } +func wrapCodeBlock(text string, width int) string { + if width <= 0 { + return text + } + + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + expanded := strings.ReplaceAll(line, "\t", " ") + runes := []rune(expanded) + if len(runes) == 0 { + out = append(out, "") + continue + } + for len(runes) > width { + out = append(out, string(runes[:width])) + runes = runes[width:] + } + out = append(out, string(runes)) + } + return strings.Join(out, "\n") +} + func trimRunes(text string, limit int) string { runes := []rune(text) if len(runes) <= limit || limit < 4 { diff --git a/internal/tui/update.go b/internal/tui/update.go index 10a6a9cf..fea512e0 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -3,7 +3,6 @@ package tui import ( "context" "errors" - "fmt" "strings" "github.com/charmbracelet/bubbles/key" @@ -139,18 +138,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } if key.Matches(typed, a.keys.NewSession) && !a.state.IsAgentRunning { - a.state.ActiveSessionID = "" - a.state.ActiveSessionTitle = draftSessionTitle - a.activeMessages = nil - a.state.StatusText = statusDraft - a.state.ExecutionError = "" - a.state.CurrentTool = "" - a.input.Reset() - a.state.InputText = "" - a.focus = panelInput - a.applyFocus() - a.resizeComponents() - a.rebuildTranscript() + a.startDraftSession() return a, tea.Batch(cmds...) } @@ -194,8 +182,15 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.InputText = "" a.resizeComponents() + if handled, cmd := a.handleImmediateSlashCommand(input); handled { + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + } + switch strings.ToLower(input) { - case slashCommandProviderPick: + case slashCommandProvider: if err := a.refreshProviderPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() @@ -205,7 +200,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } a.openProviderPicker() return a, tea.Batch(cmds...) - case slashCommandModelPicker: + case slashCommandModelPick: if err := a.refreshModelPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() @@ -218,11 +213,8 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } if strings.HasPrefix(input, slashPrefix) { - err := fmt.Sprintf("unknown command %q", input) - a.state.ExecutionError = err - a.state.StatusText = err - a.appendInlineMessage(roleError, err) - a.rebuildTranscript() + a.state.StatusText = statusApplyingCommand + cmds = append(cmds, runLocalCommand(a.configManager, a.providerSvc, input)) return a, tea.Batch(cmds...) } @@ -246,7 +238,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.input, cmd = a.input.Update(msg) a.state.InputText = a.input.Value() a.normalizeComposerHeight() - a.resizeComponents() + a.resizeComposerLayout() cmds = append(cmds, cmd) return a, tea.Batch(cmds...) } @@ -406,6 +398,11 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { if payload, ok := event.Payload.(string); ok { a.appendAssistantChunk(payload) } + case agentruntime.EventToolChunk: + if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { + a.state.StatusText = statusRunningTool + a.appendInlineMessage(roleEvent, preview(payload, 88, 4)) + } case agentruntime.EventAgentDone: a.state.IsAgentRunning = false a.state.StreamingReply = false @@ -527,8 +524,17 @@ func (a *App) applyFocus() { a.input.Blur() } +func (a *App) resizeComposerLayout() { + a.applyComponentLayout(false) +} + func (a *App) resizeComponents() { + a.applyComponentLayout(true) +} + +func (a *App) applyComponentLayout(rebuildTranscript bool) { lay := a.computeLayout() + prevTranscriptWidth := a.transcript.Width a.help.ShowAll = a.state.ShowHelp sidebarFrameWidth := a.styles.panelFocused.GetHorizontalFrameSize() sidebarFrameHeight := a.styles.panelFocused.GetVerticalFrameSize() @@ -543,7 +549,13 @@ func (a *App) resizeComponents() { a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) 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.rebuildTranscript() + if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { + a.rebuildTranscript() + return + } + if a.transcript.AtBottom() || a.state.IsAgentRunning { + a.transcript.GotoBottom() + } } func (a App) composerBoxWidth(totalWidth int) int { @@ -592,6 +604,48 @@ func (a *App) rebuildTranscript() { } } +func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { + command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) + switch command { + case slashCommandExit: + return true, tea.Quit + case slashCommandClear: + a.startDraftSession() + a.state.StatusText = "[System] Cleared current draft/history." + return true, nil + case slashCommandUndo: + if len(a.activeMessages) == 0 { + a.state.StatusText = "[System] Nothing to undo." + return true, nil + } + a.activeMessages = a.activeMessages[:len(a.activeMessages)-1] + if len(a.activeMessages) == 0 { + a.state.ActiveSessionID = "" + a.state.ActiveSessionTitle = draftSessionTitle + } + a.state.StatusText = "[System] Removed the last local entry." + a.rebuildTranscript() + return true, nil + default: + return false, nil + } +} + +func (a *App) startDraftSession() { + a.state.ActiveSessionID = "" + a.state.ActiveSessionTitle = draftSessionTitle + a.activeMessages = nil + a.state.StatusText = statusDraft + a.state.ExecutionError = "" + a.state.CurrentTool = "" + a.input.Reset() + a.state.InputText = "" + a.focus = panelInput + a.applyFocus() + a.resizeComponents() + a.rebuildTranscript() +} + func ListenForRuntimeEvent(sub <-chan agentruntime.RuntimeEvent) tea.Cmd { return func() tea.Msg { event, ok := <-sub diff --git a/internal/tui/view.go b/internal/tui/view.go index 2038c8ac..e81a6652 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -332,7 +332,12 @@ func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss. if len(lines) > 1 && !strings.Contains(lines[0], " ") && !strings.Contains(lines[0], "\t") { code = strings.Join(lines[1:], "\n") } - blocks = append(blocks, a.styles.codeBlock.Width(width).Render(a.styles.codeText.Width(max(10, width-4)).Render(code))) + codeWidth := max(10, width-4) + renderedCode := wrapCodeBlock(code, codeWidth) + if strings.TrimSpace(renderedCode) == "" { + renderedCode = emptyMessageText + } + blocks = append(blocks, a.styles.codeBlock.Width(width).Render(a.styles.codeText.Width(codeWidth).Render(renderedCode))) } if len(blocks) == 0 { From c9b674f08477e631710c2c81584cb17444842dca Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 1 Apr 2026 18:53:16 +0800 Subject: [PATCH 2/4] test: raise tui slash coverage --- internal/tui/commands_test.go | 247 ++++++++++++++++++++++++++++++++++ internal/tui/update_test.go | 89 ++++++++++++ 2 files changed, 336 insertions(+) diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index aaad2318..8e648090 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -2,9 +2,12 @@ package tui import ( "context" + "errors" "os" + "path/filepath" "strings" "testing" + "time" "neo-code/internal/config" ) @@ -222,3 +225,247 @@ func containsUsage(suggestions []commandSuggestion, usage string) bool { } return false } + +func TestExecuteLocalCommandAdditionalBranches(t *testing.T) { + t.Run("setting reports and updates values", func(t *testing.T) { + manager := newTestConfigManager(t) + providerSvc := newTestProviderService(t, manager) + + notice, err := executeLocalCommand(context.Background(), manager, providerSvc, "/setting") + if err != nil { + t.Fatalf("unexpected /setting error: %v", err) + } + if !strings.Contains(notice, "Settings:") || !strings.Contains(notice, "Provider:") { + t.Fatalf("expected settings summary, got %q", notice) + } + + nextDir := filepath.Join(t.TempDir(), "nested") + notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/setting workdir "+nextDir) + if err != nil { + t.Fatalf("unexpected /setting workdir error: %v", err) + } + if !strings.Contains(notice, nextDir) { + t.Fatalf("expected workdir update notice, got %q", notice) + } + if got := manager.Get().Workdir; got != nextDir { + t.Fatalf("expected workdir %q, got %q", nextDir, got) + } + + notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/setting provider gemini") + if err != nil { + t.Fatalf("unexpected /setting provider error: %v", err) + } + if !strings.Contains(notice, "gemini") { + t.Fatalf("expected provider switch notice, got %q", notice) + } + }) + + t.Run("setting validates arguments", func(t *testing.T) { + manager := newTestConfigManager(t) + providerSvc := newTestProviderService(t, manager) + + if _, err := executeLocalCommand(context.Background(), manager, providerSvc, "/setting model"); err == nil || !strings.Contains(err.Error(), "usage:") { + t.Fatalf("expected /setting model usage error, got %v", err) + } + if _, err := executeLocalCommand(context.Background(), manager, providerSvc, "/setting nope value"); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("expected unsupported setting error, got %v", err) + } + }) +} + +func TestExecuteFileCommandBranches(t *testing.T) { + manager := newTestConfigManager(t) + providerSvc := newTestProviderService(t, manager) + root := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = root + return nil + }); err != nil { + t.Fatalf("set temp workdir: %v", err) + } + + notice, err := executeLocalCommand(context.Background(), manager, providerSvc, "/file write notes.txt hello world") + if err != nil { + t.Fatalf("unexpected /file write error: %v", err) + } + if !strings.Contains(notice, "notes.txt") { + t.Fatalf("expected write notice, got %q", notice) + } + + notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/file read notes.txt") + if err != nil { + t.Fatalf("unexpected /file read error: %v", err) + } + if !strings.Contains(notice, "hello world") { + t.Fatalf("expected file contents, got %q", notice) + } + + notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/file list .") + if err != nil { + t.Fatalf("unexpected /file list dir error: %v", err) + } + if !strings.Contains(notice, "notes.txt") { + t.Fatalf("expected file listing, got %q", notice) + } + + notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/file list notes.txt") + if err != nil { + t.Fatalf("unexpected /file list file error: %v", err) + } + if !strings.Contains(notice, "File:") { + t.Fatalf("expected file metadata notice, got %q", notice) + } + + if _, err := executeLocalCommand(context.Background(), manager, providerSvc, "/file nope"); err == nil || !strings.Contains(err.Error(), "usage:") { + t.Fatalf("expected /file usage error, got %v", err) + } + if _, err := executeLocalCommand(context.Background(), manager, providerSvc, "/file read ../outside.txt"); err == nil || !strings.Contains(err.Error(), "escapes workspace root") { + t.Fatalf("expected workspace escape error, got %v", err) + } +} + +func TestCommandHelperFunctions(t *testing.T) { + 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("indentBlock handles empty output", func(t *testing.T) { + if got := indentBlock("", " "); got != " (no output)" { + t.Fatalf("unexpected empty indent output %q", got) + } + }) + + t.Run("shell args cover known shells", func(t *testing.T) { + if got := shellArgs("powershell", "echo hi"); len(got) < 3 || got[0] != "powershell" { + t.Fatalf("unexpected powershell args %+v", got) + } + if got := shellArgs("bash", "echo hi"); len(got) != 3 || got[0] != "bash" { + t.Fatalf("unexpected bash args %+v", got) + } + if got := shellArgs("sh", "echo hi"); len(got) != 3 || got[0] != "sh" { + t.Fatalf("unexpected sh args %+v", got) + } + if got := shellArgs("unknown", "echo hi"); len(got) < 3 || got[0] != "powershell" { + t.Fatalf("unexpected default args %+v", got) + } + }) + + t.Run("resolve workspace and workdir paths", func(t *testing.T) { + root := t.TempDir() + inside, err := resolveWorkspacePath(root, "sub\\file.txt") + if err != nil || !strings.HasSuffix(inside, filepath.Join("sub", "file.txt")) { + t.Fatalf("unexpected inside path %q / %v", inside, err) + } + if _, err := resolveWorkspacePath(root, "..\\escape.txt"); err == nil { + t.Fatalf("expected workspace escape error") + } + if _, err := resolveWorkspacePath(root, ""); err == nil { + t.Fatalf("expected empty path error") + } + if _, err := resolveRequestedWorkdir(root, ""); err == nil { + t.Fatalf("expected empty workdir error") + } + if got, err := resolveRequestedWorkdir(root, "child"); err != nil || !strings.HasSuffix(got, "child") { + t.Fatalf("unexpected relative workdir %q / %v", got, err) + } + }) +} + +func TestDefaultShellCommandExecutorBranches(t *testing.T) { + cfg := config.Config{ + Workdir: t.TempDir(), + Shell: "powershell", + ToolTimeoutSec: 1, + } + + if _, err := defaultShellCommandExecutor(context.Background(), cfg, ""); err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("expected empty command error, got %v", err) + } + + output, err := defaultShellCommandExecutor(context.Background(), cfg, "Write-Output 'hello'") + if err != nil { + t.Fatalf("unexpected executor success error: %v", err) + } + if !strings.Contains(output, "hello") { + t.Fatalf("expected hello output, got %q", output) + } + + if _, err := defaultShellCommandExecutor(context.Background(), cfg, "throw 'boom'"); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected command failure, got %v", err) + } + + start := time.Now() + if _, err := defaultShellCommandExecutor(context.Background(), cfg, "Start-Sleep -Seconds 2"); err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("expected timeout error, got %v", err) + } + if time.Since(start) > 3*time.Second { + t.Fatalf("timeout test took too long") + } +} + +func TestLocalCommandCommandWrappers(t *testing.T) { + manager := newTestConfigManager(t) + providerSvc := newTestProviderService(t, manager) + + msg := runLocalCommand(manager, providerSvc, "/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 TestExecuteStatusRunAndGitErrors(t *testing.T) { + previousExecutor := shellCommandExecutor + t.Cleanup(func() { shellCommandExecutor = previousExecutor }) + shellCommandExecutor = func(ctx context.Context, cfg config.Config, command string) (string, error) { + return "", errors.New("executor boom") + } + + manager := newTestConfigManager(t) + + if notice, err := executeStatusCommand(context.Background(), manager); err != nil || !strings.Contains(notice, "Git: unavailable") { + t.Fatalf("expected status fallback, got notice=%q err=%v", notice, err) + } + if _, err := executeRunCommand(context.Background(), manager, ""); err == nil || !strings.Contains(err.Error(), "usage:") { + t.Fatalf("expected /run usage error, got %v", err) + } + if _, err := executeRunCommand(context.Background(), manager, "echo hi"); err == nil || !strings.Contains(err.Error(), "executor boom") { + t.Fatalf("expected /run executor error, got %v", err) + } + if _, err := executeGitCommand(context.Background(), manager, ""); err == nil || !strings.Contains(err.Error(), "usage:") { + t.Fatalf("expected /git usage error, got %v", err) + } + if _, err := executeGitCommand(context.Background(), manager, "status"); err == nil || !strings.Contains(err.Error(), "executor boom") { + t.Fatalf("expected /git executor error, got %v", err) + } +} + +func TestExecuteFileCommandContextError(t *testing.T) { + manager := newTestConfigManager(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := executeFileCommand(ctx, manager, "list ."); err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled context error, got %v", err) + } +} + +func TestExecuteProviderCommandErrors(t *testing.T) { + manager := newTestConfigManager(t) + providerSvc := newTestProviderService(t, manager) + + if _, err := executeProviderCommand(context.Background(), providerSvc, ""); err == nil || !strings.Contains(err.Error(), "usage:") { + t.Fatalf("expected provider usage error, got %v", err) + } +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 97a8721d..c88a890a 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -1175,6 +1175,95 @@ func TestAppRefreshErrorPaths(t *testing.T) { }) } +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("/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.activeMessages = []provider.Message{{Role: roleUser, Content: "hello"}} + handled, cmd = app.handleImmediateSlashCommand("/undo") + if !handled || cmd != nil { + t.Fatalf("expected /undo to be handled locally") + } + if len(app.activeMessages) != 0 { + t.Fatalf("expected /undo to remove last entry") + } + + app.state.IsAgentRunning = false + app.transcript.Width = 40 + app.transcript.Height = 4 + app.transcript.SetContent(strings.Repeat("line\n", 20)) + app.transcript.GotoBottom() + app.resizeComposerLayout() + if app.transcript.Width <= 0 || app.transcript.Height <= 0 { + t.Fatalf("expected resizeComposerLayout to keep transcript dimensions positive") + } +} + +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 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "chunk output") { + t.Fatalf("expected tool chunk preview to append inline message") + } + + 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.renderMessageContent("```\n```", 20, app.styles.messageBody) + if !strings.Contains(rendered, emptyMessageText) { + t.Fatalf("expected empty code block placeholder, got %q", rendered) + } +} + func newTestConfigManager(t *testing.T) *config.Manager { t.Helper() manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) From bcfc4d455439010ba5919e6be61449de61ccb132 Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 1 Apr 2026 20:50:20 +0800 Subject: [PATCH 3/4] fix: refine tui command flow and stabilize layout --- internal/tui/app.go | 4 + internal/tui/commands.go | 440 +++++---------------------------- internal/tui/commands_test.go | 422 ++++++++++--------------------- internal/tui/input_features.go | 365 +++++++++++++++++++++++++++ internal/tui/update.go | 83 +++++-- internal/tui/update_test.go | 99 +++++++- internal/tui/view.go | 55 ++++- 7 files changed, 775 insertions(+), 693 deletions(-) create mode 100644 internal/tui/input_features.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 501fcfac..830a6678 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -30,6 +30,7 @@ type App struct { transcript viewport.Model input textarea.Model activeMessages []provider.Message + fileCandidates []string focus panel width int height int @@ -138,6 +139,9 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } app.selectCurrentProvider(cfg.SelectedProvider) app.selectCurrentModel(cfg.CurrentModel) + if err := app.refreshFileCandidates(); err != nil { + return App{}, err + } app.resizeComponents() return app, nil } diff --git a/internal/tui/commands.go b/internal/tui/commands.go index d29e3244..18dfea81 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -2,15 +2,8 @@ package tui import ( "context" - "errors" "fmt" - "net/url" - "os" - "os/exec" - "path/filepath" - "sort" "strings" - "time" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -25,29 +18,14 @@ const ( slashCommandExit = "/exit" slashCommandClear = "/clear" slashCommandStatus = "/status" - slashCommandRun = "/run" - slashCommandGit = "/git" - slashCommandFile = "/file" - slashCommandPlan = "/plan" - slashCommandUndo = "/undo" slashCommandProvider = "/provider" - slashCommandSetting = "/setting" - slashCommandSet = "/set" slashCommandModelPick = "/model" slashUsageHelp = "/help" slashUsageExit = "/exit" slashUsageClear = "/clear" slashUsageStatus = "/status" - slashUsageRun = "/run " - slashUsageGit = "/git " - slashUsageFile = "/file ..." - slashUsagePlan = "/plan" - slashUsageUndo = "/undo" slashUsageProvider = "/provider" - slashUsageSetting = "/setting [provider|model|workdir]" - slashUsageSetURL = "/set url " - slashUsageSetKey = "/set key " slashUsageModel = "/model" commandMenuTitle = "Commands" @@ -76,6 +54,8 @@ const ( statusDraft = "New draft" statusRunning = "Running" statusApplyingCommand = "Applying local command" + statusRunningCommand = "Running command" + statusCommandDone = "Command finished" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" @@ -105,25 +85,29 @@ type commandSuggestion struct { Match bool } +type statusSnapshot struct { + ActiveSessionID string + ActiveSessionTitle string + IsAgentRunning bool + CurrentProvider string + CurrentModel string + CurrentWorkdir string + CurrentTool string + ExecutionError string + FocusLabel string + PickerLabel string + MessageCount int +} + var builtinSlashCommands = []slashCommand{ {Usage: slashUsageHelp, Description: "Show slash command help"}, {Usage: slashUsageClear, Description: "Clear the current draft transcript"}, - {Usage: slashUsageStatus, Description: "Show workspace and Git status"}, - {Usage: slashUsageRun, Description: "Run a shell command inside the workspace"}, - {Usage: slashUsageGit, Description: "Run a Git command in the workspace"}, - {Usage: slashUsageFile, Description: "Read, write, or list workspace files"}, - {Usage: slashUsagePlan, Description: "Generate a local task plan"}, - {Usage: slashUsageUndo, Description: "Undo the last local transcript entry"}, + {Usage: slashUsageStatus, Description: "Show current session and agent status"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, - {Usage: slashUsageSetting, Description: "Show or change local settings"}, - {Usage: slashUsageSetURL, Description: "Set the API Base URL"}, - {Usage: slashUsageSetKey, Description: "Update the API key"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, {Usage: slashUsageExit, Description: "Exit NeoCode"}, } -var shellCommandExecutor = defaultShellCommandExecutor - func newSelectionPicker(items []list.Item) list.Model { delegate := list.NewDefaultDelegate() picker := list.New(items, delegate, 0, 0) @@ -240,6 +224,9 @@ func (a App) matchingSlashCommands(input string) []commandSuggestion { } 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) @@ -251,6 +238,15 @@ func (a App) matchingSlashCommands(input string) []commandSuggestion { return out } +func isCompleteSlashCommand(input string) bool { + for _, command := range builtinSlashCommands { + if strings.EqualFold(strings.TrimSpace(command.Usage), strings.TrimSpace(input)) { + return true + } + } + return false +} + func runProviderSelection(providerSvc ProviderController, providerName string) tea.Cmd { return func() tea.Msg { selection, err := providerSvc.SelectProvider(context.Background(), providerName) @@ -275,14 +271,14 @@ func runModelSelection(providerSvc ProviderController, modelID string) tea.Cmd { } } -func runLocalCommand(configManager *config.Manager, providerSvc ProviderController, raw string) tea.Cmd { +func runLocalCommand(configManager *config.Manager, providerSvc ProviderController, snapshot statusSnapshot, raw string) tea.Cmd { return func() tea.Msg { - notice, err := executeLocalCommand(context.Background(), configManager, providerSvc, raw) + notice, err := executeLocalCommand(context.Background(), configManager, providerSvc, snapshot, raw) return localCommandResultMsg{notice: notice, err: err} } } -func executeLocalCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, raw string) (string, error) { +func executeLocalCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, snapshot statusSnapshot, raw string) (string, error) { fields := strings.Fields(strings.TrimSpace(raw)) if len(fields) == 0 { return "", fmt.Errorf("empty command") @@ -292,247 +288,55 @@ func executeLocalCommand(ctx context.Context, configManager *config.Manager, pro case slashCommandHelp: return slashHelpText(), nil case slashCommandStatus: - return executeStatusCommand(ctx, configManager) - case slashCommandRun: - return executeRunCommand(ctx, configManager, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandRun))) - case slashCommandGit: - return executeGitCommand(ctx, configManager, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandGit))) - case slashCommandFile: - return executeFileCommand(ctx, configManager, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandFile))) - case slashCommandPlan: - return buildGenericPlanNotice(), nil + return executeStatusCommand(snapshot), nil case slashCommandProvider: return executeProviderCommand(ctx, providerSvc, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandProvider))) - case slashCommandSetting: - return executeSettingCommand(ctx, configManager, providerSvc, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandSetting))) - case slashCommandSet: - return executeSetCommand(ctx, configManager, providerSvc, fields) default: return "", fmt.Errorf("unknown command %q", fields[0]) } } -func executeSetCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, fields []string) (string, error) { - if len(fields) < 3 { - return "", fmt.Errorf("usage: %s | %s | %s", slashUsageSetURL, slashUsageSetKey, slashUsageModel) - } - - value := strings.TrimSpace(strings.Join(fields[2:], " ")) - if value == "" { - return "", fmt.Errorf("command value is empty") - } - - switch strings.ToLower(fields[1]) { - case "url": - if _, err := url.ParseRequestURI(value); err != nil { - return "", fmt.Errorf("invalid url: %w", err) - } - if err := configManager.Update(ctx, func(cfg *config.Config) error { - selectedName := strings.TrimSpace(cfg.SelectedProvider) - for i := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), selectedName) { - cfg.Providers[i].BaseURL = value - return nil - } - } - return fmt.Errorf("selected provider %q not found", cfg.SelectedProvider) - }); err != nil { - return "", err - } - cfg := configManager.Get() - return fmt.Sprintf("[System] Base URL updated for %s -> %s", cfg.SelectedProvider, value), nil - case "key": - cfg := configManager.Get() - selected, err := cfg.SelectedProviderConfig() - if err != nil { - return "", err - } - if err := os.Setenv(selected.APIKeyEnv, value); err != nil { - return "", fmt.Errorf("set api key env: %w", err) - } - return fmt.Sprintf("[System] %s updated for the current process.", selected.APIKeyEnv), nil - case "model": - selection, err := providerSvc.SetCurrentModel(ctx, value) - if err != nil { - return "", err - } - return fmt.Sprintf("[System] Current model switched to %s.", selection.ModelID), nil - default: - return "", fmt.Errorf("unsupported /set field %q", fields[1]) - } -} - -func executeStatusCommand(ctx context.Context, configManager *config.Manager) (string, error) { - cfg := configManager.Get() - lines := []string{ - "Workspace status:", - "Workdir: " + cfg.Workdir, - "Provider: " + cfg.SelectedProvider, - "Model: " + cfg.CurrentModel, - "Config: " + configManager.ConfigPath(), +func executeStatusCommand(snapshot statusSnapshot) string { + sessionID := snapshot.ActiveSessionID + if strings.TrimSpace(sessionID) == "" { + sessionID = "" } - - gitStatus, err := shellCommandExecutor(ctx, cfg, "git status --short --branch") - if err != nil { - lines = append(lines, "Git: unavailable ("+err.Error()+")") - } else { - lines = append(lines, "Git:") - lines = append(lines, indentBlock(gitStatus, " ")) + sessionTitle := snapshot.ActiveSessionTitle + if strings.TrimSpace(sessionTitle) == "" { + sessionTitle = draftSessionTitle } - - return strings.Join(lines, "\n"), nil -} - -func executeRunCommand(ctx context.Context, configManager *config.Manager, command string) (string, error) { - command = strings.TrimSpace(command) - if command == "" { - return "", fmt.Errorf("usage: %s", slashUsageRun) + running := "no" + if snapshot.IsAgentRunning { + running = "yes" } - - cfg := configManager.Get() - output, err := shellCommandExecutor(ctx, cfg, command) - if err != nil { - return "", err - } - return "Run output:\n" + indentBlock(output, " "), nil -} - -func executeGitCommand(ctx context.Context, configManager *config.Manager, args string) (string, error) { - args = strings.TrimSpace(args) - if args == "" { - return "", fmt.Errorf("usage: %s", slashUsageGit) - } - - cfg := configManager.Get() - output, err := shellCommandExecutor(ctx, cfg, "git "+args) - if err != nil { - return "", err - } - return "Git output:\n" + indentBlock(output, " "), nil -} - -func executeFileCommand(ctx context.Context, configManager *config.Manager, args string) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - cfg := configManager.Get() - subcommand, remainder := splitFirstWord(args) - switch strings.ToLower(subcommand) { - case "read": - path, _ := splitFirstWord(remainder) - if strings.TrimSpace(path) == "" { - return "", fmt.Errorf("usage: %s", slashUsageFile) - } - target, err := resolveWorkspacePath(cfg.Workdir, path) - if err != nil { - return "", err - } - data, err := os.ReadFile(target) - if err != nil { - return "", err - } - return fmt.Sprintf("File: %s\n%s", target, string(data)), nil - case "write": - path, content := splitFirstWord(remainder) - if strings.TrimSpace(path) == "" || strings.TrimSpace(content) == "" { - return "", fmt.Errorf("usage: /file write ") - } - target, err := resolveWorkspacePath(cfg.Workdir, path) - if err != nil { - return "", err - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return "", err - } - if err := os.WriteFile(target, []byte(content), 0o644); err != nil { - return "", err - } - return fmt.Sprintf("Wrote %d bytes to %s", len(content), target), nil - case "list": - path := strings.TrimSpace(remainder) - if path == "" { - path = "." - } - target, err := resolveWorkspacePath(cfg.Workdir, path) - if err != nil { - return "", err - } - info, err := os.Stat(target) - if err != nil { - return "", err - } - if !info.IsDir() { - return fmt.Sprintf("File: %s (%d bytes)", target, info.Size()), nil - } - entries, err := os.ReadDir(target) - if err != nil { - return "", err - } - names := make([]string, 0, len(entries)) - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() { - name += string(os.PathSeparator) - } - names = append(names, name) - } - sort.Strings(names) - if len(names) == 0 { - return fmt.Sprintf("Directory %s is empty", target), nil - } - return fmt.Sprintf("Files in %s:\n%s", target, indentBlock(strings.Join(names, "\n"), " ")), nil - default: - return "", fmt.Errorf("usage: %s", slashUsageFile) + currentTool := snapshot.CurrentTool + if strings.TrimSpace(currentTool) == "" { + currentTool = "" } -} - -func executeSettingCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, args string) (string, error) { - cfg := configManager.Get() - subcommand, remainder := splitFirstWord(args) - if subcommand == "" { - return strings.Join([]string{ - "Settings:", - "Provider: " + cfg.SelectedProvider, - "Model: " + cfg.CurrentModel, - "Workdir: " + cfg.Workdir, - "Shell: " + cfg.Shell, - "Config: " + configManager.ConfigPath(), - }, "\n"), nil + errorText := snapshot.ExecutionError + if strings.TrimSpace(errorText) == "" { + errorText = "" } - - value := strings.TrimSpace(remainder) - if value == "" { - return "", fmt.Errorf("usage: %s", slashUsageSetting) + picker := snapshot.PickerLabel + if strings.TrimSpace(picker) == "" { + picker = "none" } - switch strings.ToLower(subcommand) { - case "model": - selection, err := providerSvc.SetCurrentModel(ctx, value) - if err != nil { - return "", err - } - return fmt.Sprintf("[System] Current model switched to %s.", selection.ModelID), nil - case "workdir": - next, err := resolveRequestedWorkdir(cfg.Workdir, value) - if err != nil { - return "", err - } - if err := configManager.Update(ctx, func(cfg *config.Config) error { - cfg.Workdir = next - return nil - }); err != nil { - return "", err - } - if _, err := configManager.Reload(ctx); err != nil { - return "", fmt.Errorf("reload config: %w", err) - } - return "[System] Workdir updated to " + next, nil - case "provider": - return executeProviderCommand(ctx, providerSvc, value) - default: - return "", fmt.Errorf("unsupported /setting field %q", subcommand) + 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 executeProviderCommand(ctx context.Context, providerSvc ProviderController, value string) (string, error) { @@ -555,16 +359,6 @@ func slashHelpText() string { return strings.Join(lines, "\n") } -func buildGenericPlanNotice() string { - return strings.Join([]string{ - "Suggested plan:", - "1. Inspect the relevant files and current state.", - "2. Confirm the smallest safe change set.", - "3. Implement the change.", - "4. Run verification and review the output.", - }, "\n") -} - func splitFirstWord(input string) (string, string) { input = strings.TrimSpace(input) if input == "" { @@ -576,105 +370,3 @@ func splitFirstWord(input string) (string, string) { } return input[:index], strings.TrimSpace(input[index+1:]) } - -func indentBlock(text string, prefix string) string { - text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n") - if text == "" { - return prefix + "(no output)" - } - lines := strings.Split(text, "\n") - for i, line := range lines { - lines[i] = prefix + line - } - return strings.Join(lines, "\n") -} - -func defaultShellCommandExecutor(ctx context.Context, cfg config.Config, command string) (string, error) { - command = strings.TrimSpace(command) - if command == "" { - return "", errors.New("command is empty") - } - - 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 = cfg.Workdir - output, err := cmd.CombinedOutput() - text := strings.TrimSpace(string(output)) - if runCtx.Err() == context.DeadlineExceeded { - if text == "" { - return "", fmt.Errorf("command timed out after %ds", timeoutSec) - } - return "", fmt.Errorf("command timed out after %ds\n%s", timeoutSec, text) - } - if err != nil { - if text == "" { - return "", err - } - return "", fmt.Errorf("%w\n%s", err, text) - } - if text == "" { - return "(no output)", nil - } - return text, nil -} - -func shellArgs(shell string, command string) []string { - switch strings.ToLower(strings.TrimSpace(shell)) { - case "powershell", "pwsh": - return []string{"powershell", "-NoProfile", "-Command", command} - case "bash": - return []string{"bash", "-lc", command} - case "sh": - return []string{"sh", "-lc", command} - default: - return []string{"powershell", "-NoProfile", "-Command", command} - } -} - -func resolveWorkspacePath(root string, requested string) (string, error) { - base, err := filepath.Abs(root) - if err != nil { - return "", err - } - - target := strings.TrimSpace(requested) - if target == "" { - return "", errors.New("path is empty") - } - if !filepath.IsAbs(target) { - target = filepath.Join(base, target) - } - - target, err = filepath.Abs(target) - if err != nil { - return "", err - } - - rel, err := filepath.Rel(base, target) - if err != nil { - return "", err - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { - return "", errors.New("path escapes workspace root") - } - return target, nil -} - -func resolveRequestedWorkdir(current string, requested string) (string, error) { - requested = strings.TrimSpace(requested) - if requested == "" { - return "", errors.New("workdir is empty") - } - if filepath.IsAbs(requested) { - return filepath.Clean(requested), nil - } - return filepath.Abs(filepath.Join(current, requested)) -} diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 8e648090..903bdaa9 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -2,23 +2,15 @@ package tui import ( "context" - "errors" - "os" - "path/filepath" + "encoding/binary" "strings" "testing" - "time" + "unicode/utf16" "neo-code/internal/config" ) func TestExecuteLocalCommand(t *testing.T) { - previousExecutor := shellCommandExecutor - t.Cleanup(func() { shellCommandExecutor = previousExecutor }) - shellCommandExecutor = func(ctx context.Context, cfg config.Config, command string) (string, error) { - return "stubbed: " + command, nil - } - tests := []struct { name string command string @@ -26,47 +18,52 @@ func TestExecuteLocalCommand(t *testing.T) { assert func(t *testing.T, manager *config.Manager, notice string) }{ { - name: "help lists slash commands", + name: "help lists supported slash commands", command: "/help", assert: func(t *testing.T, manager *config.Manager, notice string) { t.Helper() - if !strings.Contains(notice, slashUsageHelp) || !strings.Contains(notice, slashUsageExit) { - t.Fatalf("expected help output to list slash commands, got %q", notice) + for _, want := range []string{ + slashUsageHelp, + slashUsageClear, + slashUsageStatus, + slashUsageProvider, + slashUsageModel, + slashUsageExit, + } { + if !strings.Contains(notice, want) { + t.Fatalf("expected help output to contain %q, got %q", want, notice) + } } - }, - }, - { - name: "status includes workspace details", - command: "/status", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - if !strings.Contains(notice, "Workspace status:") || !strings.Contains(notice, "stubbed: git status --short --branch") { - t.Fatalf("expected status output, got %q", 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: "run executes shell command", - command: "/run echo hi", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - if !strings.Contains(notice, "stubbed: echo hi") { - t.Fatalf("expected run output, got %q", notice) - } - }, - }, - { - name: "git executes git command", - command: "/git status", + name: "status includes current tui snapshot", + command: "/status", assert: func(t *testing.T, manager *config.Manager, notice string) { t.Helper() - if !strings.Contains(notice, "stubbed: git status") { - t.Fatalf("expected git output, got %q", notice) + 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", + name: "provider switches current provider when arg is provided", command: "/provider gemini", assert: func(t *testing.T, manager *config.Manager, notice string) { t.Helper() @@ -80,52 +77,7 @@ func TestExecuteLocalCommand(t *testing.T) { }, }, { - name: "set url updates selected provider", - command: "/set url https://test.example/v1", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - cfg := manager.Get() - selected, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if selected.BaseURL != "https://test.example/v1" { - t.Fatalf("expected updated base url, got %q", selected.BaseURL) - } - if !strings.Contains(notice, "Base URL updated") { - t.Fatalf("expected update notice, got %q", notice) - } - }, - }, - { - name: "set model updates current model", - command: "/set model gpt-4.1", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - cfg := manager.Get() - if cfg.CurrentModel != "gpt-4.1" { - t.Fatalf("expected current model gpt-4.1, got %q", cfg.CurrentModel) - } - if !strings.Contains(notice, "Current model switched") { - t.Fatalf("expected model switch notice, got %q", notice) - } - }, - }, - { - name: "set key updates process env", - command: "/set key secret-key", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - if got := strings.TrimSpace(os.Getenv(config.OpenAIDefaultAPIKeyEnv)); got != "secret-key" { - t.Fatalf("expected env to be reloaded, got %q", got) - } - if !strings.Contains(notice, "updated for the current process") { - t.Fatalf("expected key reload notice, got %q", notice) - } - }, - }, - { - name: "provider requires an argument", + name: "provider without arg returns usage", command: "/provider", expectErr: "usage:", }, @@ -134,16 +86,6 @@ func TestExecuteLocalCommand(t *testing.T) { command: "/unknown", expectErr: `unknown command "/unknown"`, }, - { - name: "set usage requires enough arguments", - command: "/set url", - expectErr: "usage:", - }, - { - name: "invalid url is rejected", - command: "/set url not-a-url", - expectErr: "invalid url", - }, { name: "empty command is rejected", command: " ", @@ -156,7 +98,7 @@ func TestExecuteLocalCommand(t *testing.T) { t.Run(tt.name, func(t *testing.T) { manager := newTestConfigManager(t) providerSvc := newTestProviderService(t, manager) - notice, err := executeLocalCommand(context.Background(), manager, providerSvc, tt.command) + 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) @@ -189,7 +131,7 @@ func TestMatchingSlashCommands(t *testing.T) { expectCount: 0, }, { - name: "bare slash returns all commands", + name: "bare slash returns supported commands only", input: "/", expectCount: len(builtinSlashCommands), expectUsage: slashUsageHelp, @@ -200,6 +142,11 @@ func TestMatchingSlashCommands(t *testing.T) { expectCount: 1, expectUsage: slashUsageModel, }, + { + name: "complete slash command hides suggestions", + input: "/status", + expectCount: 0, + }, } for _, tt := range tests { @@ -226,101 +173,15 @@ func containsUsage(suggestions []commandSuggestion, usage string) bool { return false } -func TestExecuteLocalCommandAdditionalBranches(t *testing.T) { - t.Run("setting reports and updates values", func(t *testing.T) { - manager := newTestConfigManager(t) - providerSvc := newTestProviderService(t, manager) - - notice, err := executeLocalCommand(context.Background(), manager, providerSvc, "/setting") - if err != nil { - t.Fatalf("unexpected /setting error: %v", err) - } - if !strings.Contains(notice, "Settings:") || !strings.Contains(notice, "Provider:") { - t.Fatalf("expected settings summary, got %q", notice) - } - - nextDir := filepath.Join(t.TempDir(), "nested") - notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/setting workdir "+nextDir) - if err != nil { - t.Fatalf("unexpected /setting workdir error: %v", err) - } - if !strings.Contains(notice, nextDir) { - t.Fatalf("expected workdir update notice, got %q", notice) - } - if got := manager.Get().Workdir; got != nextDir { - t.Fatalf("expected workdir %q, got %q", nextDir, got) - } - - notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/setting provider gemini") - if err != nil { - t.Fatalf("unexpected /setting provider error: %v", err) - } - if !strings.Contains(notice, "gemini") { - t.Fatalf("expected provider switch notice, got %q", notice) - } - }) - - t.Run("setting validates arguments", func(t *testing.T) { - manager := newTestConfigManager(t) - providerSvc := newTestProviderService(t, manager) - - if _, err := executeLocalCommand(context.Background(), manager, providerSvc, "/setting model"); err == nil || !strings.Contains(err.Error(), "usage:") { - t.Fatalf("expected /setting model usage error, got %v", err) - } - if _, err := executeLocalCommand(context.Background(), manager, providerSvc, "/setting nope value"); err == nil || !strings.Contains(err.Error(), "unsupported") { - t.Fatalf("expected unsupported setting error, got %v", err) - } - }) -} - -func TestExecuteFileCommandBranches(t *testing.T) { - manager := newTestConfigManager(t) - providerSvc := newTestProviderService(t, manager) - root := t.TempDir() - if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.Workdir = root - return nil - }); err != nil { - t.Fatalf("set temp workdir: %v", err) - } - - notice, err := executeLocalCommand(context.Background(), manager, providerSvc, "/file write notes.txt hello world") - if err != nil { - t.Fatalf("unexpected /file write error: %v", err) - } - if !strings.Contains(notice, "notes.txt") { - t.Fatalf("expected write notice, got %q", notice) - } - - notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/file read notes.txt") - if err != nil { - t.Fatalf("unexpected /file read error: %v", err) - } - if !strings.Contains(notice, "hello world") { - t.Fatalf("expected file contents, got %q", notice) - } - - notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/file list .") - if err != nil { - t.Fatalf("unexpected /file list dir error: %v", err) - } - if !strings.Contains(notice, "notes.txt") { - t.Fatalf("expected file listing, got %q", notice) - } - - notice, err = executeLocalCommand(context.Background(), manager, providerSvc, "/file list notes.txt") - if err != nil { - t.Fatalf("unexpected /file list file error: %v", err) - } - if !strings.Contains(notice, "File:") { - t.Fatalf("expected file metadata notice, got %q", notice) - } - - if _, err := executeLocalCommand(context.Background(), manager, providerSvc, "/file nope"); err == nil || !strings.Contains(err.Error(), "usage:") { - t.Fatalf("expected /file usage error, got %v", err) - } - if _, err := executeLocalCommand(context.Background(), manager, providerSvc, "/file read ../outside.txt"); err == nil || !strings.Contains(err.Error(), "escapes workspace root") { - t.Fatalf("expected workspace escape error, got %v", err) +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", } } @@ -334,85 +195,71 @@ func TestCommandHelperFunctions(t *testing.T) { } }) - t.Run("indentBlock handles empty output", func(t *testing.T) { - if got := indentBlock("", " "); got != " (no output)" { - t.Fatalf("unexpected empty indent output %q", got) - } - }) - - t.Run("shell args cover known shells", func(t *testing.T) { - if got := shellArgs("powershell", "echo hi"); len(got) < 3 || got[0] != "powershell" { + 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 got := shellArgs("bash", "echo hi"); len(got) != 3 || got[0] != "bash" { - t.Fatalf("unexpected bash args %+v", got) - } - if got := shellArgs("sh", "echo hi"); len(got) != 3 || got[0] != "sh" { - t.Fatalf("unexpected sh args %+v", got) - } - if got := shellArgs("unknown", "echo hi"); len(got) < 3 || got[0] != "powershell" { - t.Fatalf("unexpected default 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("resolve workspace and workdir paths", func(t *testing.T) { - root := t.TempDir() - inside, err := resolveWorkspacePath(root, "sub\\file.txt") - if err != nil || !strings.HasSuffix(inside, filepath.Join("sub", "file.txt")) { - t.Fatalf("unexpected inside path %q / %v", inside, err) - } - if _, err := resolveWorkspacePath(root, "..\\escape.txt"); err == nil { - t.Fatalf("expected workspace escape error") - } - if _, err := resolveWorkspacePath(root, ""); err == nil { - t.Fatalf("expected empty path error") + 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) } - if _, err := resolveRequestedWorkdir(root, ""); err == nil { - t.Fatalf("expected empty workdir error") - } - if got, err := resolveRequestedWorkdir(root, "child"); err != nil || !strings.HasSuffix(got, "child") { - t.Fatalf("unexpected relative workdir %q / %v", got, err) + 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) + } } }) -} - -func TestDefaultShellCommandExecutorBranches(t *testing.T) { - cfg := config.Config{ - Workdir: t.TempDir(), - Shell: "powershell", - ToolTimeoutSec: 1, - } - if _, err := defaultShellCommandExecutor(context.Background(), cfg, ""); err == nil || !strings.Contains(err.Error(), "empty") { - t.Fatalf("expected empty command error, got %v", err) - } + t.Run("sanitize workspace output decodes utf16le launcher errors", func(t *testing.T) { + text := "适用于 Linux 的 Windows 子系统没有已安装的分发版。\r\n请运行 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...) + } - output, err := defaultShellCommandExecutor(context.Background(), cfg, "Write-Output 'hello'") - if err != nil { - t.Fatalf("unexpected executor success error: %v", err) - } - if !strings.Contains(output, "hello") { - t.Fatalf("expected hello output, got %q", output) - } + got := sanitizeWorkspaceOutput(raw) + for _, want := range []string{"适用于 Linux", "Windows 子系统", "wsl.exe --list --online"} { + if !strings.Contains(got, want) { + t.Fatalf("expected decoded output to contain %q, got %q", want, got) + } + } + }) - if _, err := defaultShellCommandExecutor(context.Background(), cfg, "throw 'boom'"); err == nil || !strings.Contains(err.Error(), "boom") { - t.Fatalf("expected command failure, got %v", err) - } + t.Run("decode workspace output prefers utf16 when chinese prefix has no zero bytes", func(t *testing.T) { + text := "拒绝访问。 \r\n错误代码: 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...) + } - start := time.Now() - if _, err := defaultShellCommandExecutor(context.Background(), cfg, "Start-Sleep -Seconds 2"); err == nil || !strings.Contains(err.Error(), "timed out") { - t.Fatalf("expected timeout error, got %v", err) - } - if time.Since(start) > 3*time.Second { - t.Fatalf("timeout test took too long") - } + got := decodeWorkspaceOutput(raw) + for _, want := range []string{"拒绝访问", "错误代码", "E_ACCESSDENIED"} { + if !strings.Contains(got, want) { + t.Fatalf("expected decoded utf16 output to contain %q, got %q", want, got) + } + } + }) } -func TestLocalCommandCommandWrappers(t *testing.T) { +func TestLocalCommandWrappers(t *testing.T) { manager := newTestConfigManager(t) providerSvc := newTestProviderService(t, manager) - msg := runLocalCommand(manager, providerSvc, "/help")() + 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) @@ -425,47 +272,34 @@ func TestLocalCommandCommandWrappers(t *testing.T) { } } -func TestExecuteStatusRunAndGitErrors(t *testing.T) { - previousExecutor := shellCommandExecutor - t.Cleanup(func() { shellCommandExecutor = previousExecutor }) - shellCommandExecutor = func(ctx context.Context, cfg config.Config, command string) (string, error) { - return "", errors.New("executor boom") - } - - manager := newTestConfigManager(t) - - if notice, err := executeStatusCommand(context.Background(), manager); err != nil || !strings.Contains(notice, "Git: unavailable") { - t.Fatalf("expected status fallback, got notice=%q err=%v", notice, err) - } - if _, err := executeRunCommand(context.Background(), manager, ""); err == nil || !strings.Contains(err.Error(), "usage:") { - t.Fatalf("expected /run usage error, got %v", err) - } - if _, err := executeRunCommand(context.Background(), manager, "echo hi"); err == nil || !strings.Contains(err.Error(), "executor boom") { - t.Fatalf("expected /run executor error, got %v", err) - } - if _, err := executeGitCommand(context.Background(), manager, ""); err == nil || !strings.Contains(err.Error(), "usage:") { - t.Fatalf("expected /git usage error, got %v", err) - } - if _, err := executeGitCommand(context.Background(), manager, "status"); err == nil || !strings.Contains(err.Error(), "executor boom") { - t.Fatalf("expected /git executor error, got %v", err) - } -} - -func TestExecuteFileCommandContextError(t *testing.T) { - manager := newTestConfigManager(t) - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - if _, err := executeFileCommand(ctx, manager, "list ."); err == nil || !errors.Is(err, context.Canceled) { - t.Fatalf("expected canceled context error, got %v", err) - } -} - -func TestExecuteProviderCommandErrors(t *testing.T) { - manager := newTestConfigManager(t) - providerSvc := newTestProviderService(t, manager) - - if _, err := executeProviderCommand(context.Background(), providerSvc, ""); err == nil || !strings.Contains(err.Error(), "usage:") { - t.Fatalf("expected provider usage error, got %v", err) +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) + } } } diff --git a/internal/tui/input_features.go b/internal/tui/input_features.go new file mode 100644 index 00000000..5391d4f6 --- /dev/null +++ b/internal/tui/input_features.go @@ -0,0 +1,365 @@ +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" +) + +const ( + workspaceCommandPrefix = "&" + workspaceCommandUsage = "& " + fileReferencePrefix = "@" + fileMenuTitle = "Files" + shellMenuTitle = "Shell" + maxWorkspaceFiles = 4000 + maxFileSuggestions = 6 +) + +type workspaceCommandResultMsg struct { + command string + output string + err error +} + +var workspaceCommandExecutor = defaultWorkspaceCommandExecutor + +var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) + +func isWorkspaceCommandInput(input string) bool { + return strings.HasPrefix(strings.TrimSpace(input), workspaceCommandPrefix) +} + +func extractWorkspaceCommand(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if !strings.HasPrefix(trimmed, workspaceCommandPrefix) { + return "", fmt.Errorf("usage: %s", workspaceCommandUsage) + } + command := strings.TrimSpace(strings.TrimPrefix(trimmed, workspaceCommandPrefix)) + if command == "" { + return "", fmt.Errorf("usage: %s", workspaceCommandUsage) + } + return command, nil +} + +func runWorkspaceCommand(configManager *config.Manager, raw string) tea.Cmd { + return func() tea.Msg { + command, output, err := executeWorkspaceCommand(context.Background(), configManager, raw) + return workspaceCommandResultMsg{ + command: command, + output: output, + err: err, + } + } +} + +func executeWorkspaceCommand(ctx context.Context, configManager *config.Manager, raw string) (string, string, error) { + command, err := extractWorkspaceCommand(raw) + if err != nil { + return "", "", err + } + + cfg := configManager.Get() + output, execErr := workspaceCommandExecutor(ctx, cfg, command) + return command, output, execErr +} + +func defaultWorkspaceCommandExecutor(ctx context.Context, cfg config.Config, command string) (string, error) { + command = strings.TrimSpace(command) + if command == "" { + return "", errors.New("command is empty") + } + + 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 = cfg.Workdir + 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 +} + +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)} + } +} + +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 +} + +func formatWorkspaceCommandResult(command string, output string, err error) string { + header := "Command" + if err != nil { + header = "Command Failed" + } + + body := strings.TrimSpace(output) + if body == "" && err != nil { + body = err.Error() + } + if body == "" { + body = "(no output)" + } + + body = strings.ReplaceAll(body, "```", "` ` `") + return fmt.Sprintf("%s: & %s\n```text\n%s\n```", header, command, body) +} + +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) +} + +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)) +} + +func (a *App) refreshFileCandidates() error { + candidates, err := collectWorkspaceFiles(a.state.CurrentWorkdir, maxWorkspaceFiles) + if err != nil { + return err + } + a.fileCandidates = candidates + 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 +} + +func (a App) matchingFileReferences(input string) []string { + _, _, token, ok := currentReferenceToken(input) + if !ok { + return nil + } + + query := strings.ToLower(strings.TrimPrefix(token, fileReferencePrefix)) + if len(a.fileCandidates) == 0 { + return nil + } + + prefixMatches := make([]string, 0, maxFileSuggestions) + containsMatches := make([]string, 0, maxFileSuggestions) + for _, candidate := range a.fileCandidates { + 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) > maxFileSuggestions { + out = out[:maxFileSuggestions] + } + return out +} + +func currentReferenceToken(input string) (start int, end int, token string, ok bool) { + if strings.TrimSpace(input) == "" { + return 0, 0, "", false + } + + end = len(input) + start = strings.LastIndexAny(input, " \t\r\n") + if start < 0 { + start = 0 + } else { + start++ + } + + token = input[start:end] + if !strings.HasPrefix(token, fileReferencePrefix) { + return 0, 0, "", false + } + return start, end, token, true +} + +func (a *App) applyTopFileSuggestion() bool { + suggestions := a.matchingFileReferences(a.input.Value()) + if len(suggestions) == 0 { + return false + } + + start, end, _, ok := currentReferenceToken(a.input.Value()) + if !ok { + return false + } + + next := a.input.Value()[:start] + fileReferencePrefix + suggestions[0] + a.input.Value()[end:] + a.input.SetValue(next) + a.state.InputText = next + a.normalizeComposerHeight() + a.resizeComposerLayout() + return true +} diff --git a/internal/tui/update.go b/internal/tui/update.go index fea512e0..58747261 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -3,6 +3,7 @@ package tui import ( "context" "errors" + "fmt" "strings" "github.com/charmbracelet/bubbles/key" @@ -103,6 +104,28 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.appendInlineMessage(roleSystem, typed.notice) } a.rebuildTranscript() + a.transcript.GotoBottom() + 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.appendInlineMessage(roleError, typed.err.Error()) + a.rebuildTranscript() + a.transcript.GotoBottom() + return a, tea.Batch(cmds...) + } + result := formatWorkspaceCommandResult(typed.command, typed.output, typed.err) + a.activeMessages = append(a.activeMessages, provider.Message{Role: roleTool, Content: result}) + if typed.err != nil { + a.state.ExecutionError = typed.err.Error() + a.state.StatusText = fmt.Sprintf("Command failed: %s", typed.command) + } else { + a.state.ExecutionError = "" + a.state.StatusText = statusCommandDone + } + a.rebuildTranscript() + a.transcript.GotoBottom() return a, tea.Batch(cmds...) case tea.KeyMsg: if key.Matches(typed, a.keys.Quit) { @@ -124,6 +147,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.state.ActivePicker != pickerNone { return a.updatePicker(typed) } + if a.focus == panelInput && key.Matches(typed, a.keys.NextPanel) && a.applyTopFileSuggestion() { + return a, tea.Batch(cmds...) + } if key.Matches(typed, a.keys.NextPanel) { a.focusNext() return a, tea.Batch(cmds...) @@ -214,7 +240,25 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te if strings.HasPrefix(input, slashPrefix) { a.state.StatusText = statusApplyingCommand - cmds = append(cmds, runLocalCommand(a.configManager, a.providerSvc, input)) + cmds = append(cmds, runLocalCommand(a.configManager, a.providerSvc, a.currentStatusSnapshot(), input)) + return a, tea.Batch(cmds...) + } + if isWorkspaceCommandInput(input) { + command, err := extractWorkspaceCommand(input) + if err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + a.rebuildTranscript() + a.transcript.GotoBottom() + return a, tea.Batch(cmds...) + } + a.state.StatusText = statusRunningCommand + a.state.ExecutionError = "" + a.appendInlineMessage(roleEvent, "Running command: "+command) + a.rebuildTranscript() + a.transcript.GotoBottom() + cmds = append(cmds, runWorkspaceCommand(a.configManager, input)) return a, tea.Batch(cmds...) } @@ -613,24 +657,35 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { a.startDraftSession() a.state.StatusText = "[System] Cleared current draft/history." return true, nil - case slashCommandUndo: - if len(a.activeMessages) == 0 { - a.state.StatusText = "[System] Nothing to undo." - return true, nil - } - a.activeMessages = a.activeMessages[:len(a.activeMessages)-1] - if len(a.activeMessages) == 0 { - a.state.ActiveSessionID = "" - a.state.ActiveSessionTitle = draftSessionTitle - } - a.state.StatusText = "[System] Removed the last local entry." - a.rebuildTranscript() - return true, nil default: return false, nil } } +func (a App) currentStatusSnapshot() statusSnapshot { + picker := "none" + switch a.state.ActivePicker { + case pickerProvider: + picker = "provider" + case pickerModel: + picker = "model" + } + + return statusSnapshot{ + ActiveSessionID: a.state.ActiveSessionID, + ActiveSessionTitle: a.state.ActiveSessionTitle, + IsAgentRunning: a.state.IsAgentRunning, + 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) startDraftSession() { a.state.ActiveSessionID = "" a.state.ActiveSessionTitle = draftSessionTitle diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index c88a890a..f9d7e061 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -365,9 +365,18 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { t.Fatalf("expected slash command menu when input starts with slash") } } + app.input.SetValue("/status") + app.state.InputText = "/status" + 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))) } @@ -1210,15 +1219,6 @@ func TestImmediateSlashCommandsAndLayoutBranches(t *testing.T) { t.Fatalf("expected quit msg from /exit") } - app.activeMessages = []provider.Message{{Role: roleUser, Content: "hello"}} - handled, cmd = app.handleImmediateSlashCommand("/undo") - if !handled || cmd != nil { - t.Fatalf("expected /undo to be handled locally") - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected /undo to remove last entry") - } - app.state.IsAgentRunning = false app.transcript.Width = 40 app.transcript.Height = 4 @@ -1228,6 +1228,11 @@ func TestImmediateSlashCommandsAndLayoutBranches(t *testing.T) { 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 TestAdditionalRenderingAndToolChunkBranches(t *testing.T) { @@ -1264,6 +1269,82 @@ func TestAdditionalRenderingAndToolChunkBranches(t *testing.T) { } } +func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { + previousExecutor := workspaceCommandExecutor + t.Cleanup(func() { workspaceCommandExecutor = previousExecutor }) + workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, 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) < 2 { + t.Fatalf("expected running event and command result, got %+v", app.activeMessages) + } + first := app.activeMessages[0] + if first.Role != roleEvent || !strings.Contains(first.Content, "Running command: git status") { + t.Fatalf("expected running command notice, got %+v", first) + } + last := app.activeMessages[len(app.activeMessages)-1] + if last.Role != roleTool || !strings.Contains(last.Content, "Command: & git status") || !strings.Contains(last.Content, "stubbed output for git status") { + t.Fatalf("expected tool transcript entry with command output, 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() + 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() + menu = app.renderCommandMenu(80) + if !strings.Contains(menu, shellMenuTitle) || !strings.Contains(menu, workspaceCommandUsage) { + t.Fatalf("expected shell hint menu, got %q", menu) + } + if strings.Count(menu, "\n") > 3 { + t.Fatalf("expected compact shell menu, got %q", menu) + } +} + func newTestConfigManager(t *testing.T) *config.Manager { t.Helper() manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) diff --git a/internal/tui/view.go b/internal/tui/view.go index e81a6652..e848fa8f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -43,7 +43,7 @@ func (a App) View() string { } func (a App) renderHeader(width int) string { - status := a.state.StatusText + status := compactStatusText(a.state.StatusText, max(18, width/3)) if a.state.IsAgentRunning { status = a.spinner.View() + " " + fallback(status, statusRunning) } @@ -272,7 +272,40 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { } func (a App) renderCommandMenu(width int) string { - suggestions := a.matchingSlashCommands(strings.TrimSpace(a.input.Value())) + input := strings.TrimSpace(a.input.Value()) + + if suggestions := a.matchingFileReferences(a.input.Value()); len(suggestions) > 0 { + lines := make([]string, 0, len(suggestions)+1) + lines = append(lines, a.styles.commandMenuTitle.Render(fileMenuTitle)) + for idx, suggestion := range suggestions { + usageStyle := a.styles.commandUsage + if idx == 0 { + usageStyle = a.styles.commandUsageMatch + } + lines = append(lines, lipgloss.JoinHorizontal( + lipgloss.Top, + usageStyle.Render("@"+suggestion), + lipgloss.NewStyle().Width(2).Render(""), + a.styles.commandDesc.Render("workspace file reference"), + )) + } + return a.styles.commandMenu.Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + } + + if isWorkspaceCommandInput(input) { + lines := []string{ + a.styles.commandMenuTitle.Render(shellMenuTitle), + lipgloss.JoinHorizontal( + lipgloss.Top, + a.styles.commandUsageMatch.Render(workspaceCommandUsage), + lipgloss.NewStyle().Width(2).Render(""), + a.styles.commandDesc.Render(trimMiddle(a.state.CurrentWorkdir, max(24, width-28))), + ), + } + return a.styles.commandMenu.Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + } + + suggestions := a.matchingSlashCommands(input) if len(suggestions) == 0 { return "" } @@ -361,6 +394,24 @@ 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 "" +} + func (a App) focusLabel() string { switch a.focus { case panelSessions: From b7bbbe1cad95d7a547b51348297ca41f3bdff7f7 Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 1 Apr 2026 21:08:17 +0800 Subject: [PATCH 4/4] test: cover tui command helpers --- internal/tui/input_features_test.go | 135 ++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 internal/tui/input_features_test.go diff --git a/internal/tui/input_features_test.go b/internal/tui/input_features_test.go new file mode 100644 index 00000000..5014e5ad --- /dev/null +++ b/internal/tui/input_features_test.go @@ -0,0 +1,135 @@ +package tui + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "neo-code/internal/config" +) + +func TestWorkspaceCommandHelpers(t *testing.T) { + 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) + } + }) +} + +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() + + suggestions := app.matchingFileReferences(app.input.Value()) + if len(suggestions) == 0 || suggestions[0] != "internal/tui/update.go" { + t.Fatalf("unexpected suggestions %v", suggestions) + } + if !app.applyTopFileSuggestion() { + 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() + if app.applyTopFileSuggestion() { + t.Fatalf("expected applyTopFileSuggestion to fail without @ token") + } + }) +}