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 4aacf6a1..18dfea81 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -8,14 +8,23 @@ import ( "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" + slashCommandProvider = "/provider" + slashCommandModelPick = "/model" + + slashUsageHelp = "/help" + slashUsageExit = "/exit" + slashUsageClear = "/clear" + slashUsageStatus = "/status" slashUsageProvider = "/provider" slashUsageModel = "/model" @@ -30,22 +39,25 @@ 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" + statusRunningCommand = "Running command" + statusCommandDone = "Command finished" + statusChooseProvider = "Choose a provider" + statusChooseModel = "Choose a model" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" @@ -73,9 +85,27 @@ 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 current session and agent status"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, + {Usage: slashUsageExit, Description: "Exit NeoCode"}, } func newSelectionPicker(items []list.Item) list.Model { @@ -194,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) @@ -205,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) @@ -228,3 +270,103 @@ func runModelSelection(providerSvc ProviderController, modelID 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, snapshot, raw) + return localCommandResultMsg{notice: notice, err: err} + } +} + +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") + } + + switch strings.ToLower(fields[0]) { + case slashCommandHelp: + return slashHelpText(), nil + case slashCommandStatus: + return executeStatusCommand(snapshot), nil + case slashCommandProvider: + return executeProviderCommand(ctx, providerSvc, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), slashCommandProvider))) + default: + return "", fmt.Errorf("unknown command %q", fields[0]) + } +} + +func executeStatusCommand(snapshot statusSnapshot) string { + sessionID := snapshot.ActiveSessionID + if strings.TrimSpace(sessionID) == "" { + sessionID = "" + } + sessionTitle := snapshot.ActiveSessionTitle + if strings.TrimSpace(sessionTitle) == "" { + sessionTitle = draftSessionTitle + } + running := "no" + if snapshot.IsAgentRunning { + running = "yes" + } + currentTool := snapshot.CurrentTool + if strings.TrimSpace(currentTool) == "" { + currentTool = "" + } + errorText := snapshot.ExecutionError + if strings.TrimSpace(errorText) == "" { + errorText = "" + } + picker := snapshot.PickerLabel + if strings.TrimSpace(picker) == "" { + picker = "none" + } + + lines := []string{ + "Status:", + "Session: " + sessionTitle, + "Session ID: " + sessionID, + "Running: " + running, + "Provider: " + snapshot.CurrentProvider, + "Model: " + snapshot.CurrentModel, + "Workdir: " + snapshot.CurrentWorkdir, + "Focus: " + snapshot.FocusLabel, + "Picker: " + picker, + "Current Tool: " + currentTool, + fmt.Sprintf("Messages: %d", snapshot.MessageCount), + "Error: " + errorText, + } + return strings.Join(lines, "\n") +} + +func 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 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:]) +} diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index af472f74..903bdaa9 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -1,6 +1,119 @@ package tui -import "testing" +import ( + "context" + "encoding/binary" + "strings" + "testing" + "unicode/utf16" + + "neo-code/internal/config" +) + +func TestExecuteLocalCommand(t *testing.T) { + tests := []struct { + name string + command string + expectErr string + assert func(t *testing.T, manager *config.Manager, notice string) + }{ + { + name: "help lists supported slash commands", + command: "/help", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + for _, want := range []string{ + slashUsageHelp, + slashUsageClear, + slashUsageStatus, + slashUsageProvider, + slashUsageModel, + slashUsageExit, + } { + if !strings.Contains(notice, want) { + t.Fatalf("expected help output to contain %q, got %q", want, notice) + } + } + for _, unwanted := range []string{"/run", "/git", "/file", "/plan", "/undo", "/setting", "/set"} { + if strings.Contains(notice, unwanted) { + t.Fatalf("expected help output not to contain %q, got %q", unwanted, notice) + } + } + }, + }, + { + name: "status includes current tui snapshot", + command: "/status", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + for _, want := range []string{ + "Status:", + "Session: Draft", + "Running: no", + "Provider: " + manager.Get().SelectedProvider, + "Model: " + manager.Get().CurrentModel, + "Focus: " + focusLabelComposer, + "Picker: none", + "Messages: 0", + } { + if !strings.Contains(notice, want) { + t.Fatalf("expected status output to contain %q, got %q", want, notice) + } + } + }, + }, + { + name: "provider switches current provider when arg is provided", + command: "/provider gemini", + assert: func(t *testing.T, manager *config.Manager, notice string) { + t.Helper() + cfg := manager.Get() + if cfg.SelectedProvider != config.GeminiName { + t.Fatalf("expected selected provider gemini, got %q", cfg.SelectedProvider) + } + if !strings.Contains(notice, "Current provider switched") { + t.Fatalf("expected provider switch notice, got %q", notice) + } + }, + }, + { + name: "provider without arg returns usage", + command: "/provider", + expectErr: "usage:", + }, + { + name: "unknown command is rejected", + command: "/unknown", + expectErr: `unknown command "/unknown"`, + }, + { + name: "empty command is rejected", + command: " ", + expectErr: "empty command", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + manager := newTestConfigManager(t) + providerSvc := newTestProviderService(t, manager) + notice, err := executeLocalCommand(context.Background(), manager, providerSvc, defaultTestStatusSnapshot(manager), tt.command) + if tt.expectErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.assert != nil { + tt.assert(t, manager, notice) + } + }) + } +} func TestMatchingSlashCommands(t *testing.T) { t.Parallel() @@ -18,10 +131,10 @@ 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: slashUsageModel, + expectUsage: slashUsageHelp, }, { name: "prefix narrows suggestions", @@ -29,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 { @@ -54,3 +172,134 @@ func containsUsage(suggestions []commandSuggestion, usage string) bool { } return false } + +func defaultTestStatusSnapshot(manager *config.Manager) statusSnapshot { + cfg := manager.Get() + return statusSnapshot{ + ActiveSessionTitle: draftSessionTitle, + CurrentProvider: cfg.SelectedProvider, + CurrentModel: cfg.CurrentModel, + CurrentWorkdir: cfg.Workdir, + FocusLabel: focusLabelComposer, + PickerLabel: "none", + } +} + +func TestCommandHelperFunctions(t *testing.T) { + t.Run("splitFirstWord handles empty and remainder", func(t *testing.T) { + if first, rest := splitFirstWord(" "); first != "" || rest != "" { + t.Fatalf("expected empty split, got %q / %q", first, rest) + } + if first, rest := splitFirstWord("alpha beta gamma"); first != "alpha" || rest != "beta gamma" { + t.Fatalf("unexpected split result %q / %q", first, rest) + } + }) + + t.Run("powershell shell args force utf8 output", func(t *testing.T) { + got := shellArgs("powershell", "git status") + if len(got) != 4 || got[0] != "powershell" { + t.Fatalf("unexpected powershell args %+v", got) + } + if !strings.Contains(got[3], "65001") || !strings.Contains(got[3], "git status") { + t.Fatalf("expected utf8 powershell wrapper, got %q", got[3]) + } + }) + + t.Run("sanitize workspace output strips ansi and invalid bytes", func(t *testing.T) { + raw := "\x1b[31mfatal\x1b[0m:\xff bad\r\nnext\x00line" + got := sanitizeWorkspaceOutput([]byte(raw)) + if strings.Contains(got, "\x1b") || strings.Contains(got, "\x00") { + t.Fatalf("expected control chars to be removed, got %q", got) + } + for _, want := range []string{"fatal", "bad", "nextline"} { + if !strings.Contains(strings.ReplaceAll(got, "\n", ""), want) { + t.Fatalf("expected sanitized output to contain %q, got %q", want, got) + } + } + }) + + t.Run("sanitize workspace output decodes utf16le launcher errors", func(t *testing.T) { + text := "适用于 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...) + } + + 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) + } + } + }) + + 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...) + } + + 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 TestLocalCommandWrappers(t *testing.T) { + manager := newTestConfigManager(t) + providerSvc := newTestProviderService(t, manager) + + msg := runLocalCommand(manager, providerSvc, defaultTestStatusSnapshot(manager), "/help")() + result, ok := msg.(localCommandResultMsg) + if !ok || result.err != nil || !strings.Contains(result.notice, "Available slash commands") { + t.Fatalf("expected help command result, got %+v", msg) + } + + msg = runProviderSelection(providerSvc, "missing-provider")() + result, ok = msg.(localCommandResultMsg) + if !ok || result.err == nil { + t.Fatalf("expected provider selection error, got %+v", msg) + } +} + +func TestExecuteStatusCommandSnapshot(t *testing.T) { + notice := executeStatusCommand(statusSnapshot{ + ActiveSessionID: "session-123", + ActiveSessionTitle: "Implement slash UX", + IsAgentRunning: true, + CurrentProvider: "openai", + CurrentModel: "gpt-5.4", + CurrentWorkdir: `D:\repo`, + CurrentTool: "bash", + ExecutionError: "tool failed", + FocusLabel: focusLabelTranscript, + PickerLabel: "model", + MessageCount: 7, + }) + for _, want := range []string{ + "Session: Implement slash UX", + "Session ID: session-123", + "Running: yes", + "Provider: openai", + "Model: gpt-5.4", + "Focus: Transcript", + "Picker: model", + "Current Tool: bash", + "Messages: 7", + "Error: tool failed", + } { + if !strings.Contains(notice, want) { + t.Fatalf("expected status output to contain %q, got %q", want, notice) + } + } +} 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/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") + } + }) +} 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..58747261 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -104,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) { @@ -125,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...) @@ -139,18 +164,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 +208,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 +226,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 +239,26 @@ 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.state.StatusText = statusApplyingCommand + 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...) } @@ -246,7 +282,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 +442,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 +568,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 +593,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 +648,59 @@ 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 + 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 + 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/update_test.go b/internal/tui/update_test.go index 97a8721d..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))) } @@ -1175,6 +1184,167 @@ 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.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") + } + + 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) { + 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 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 2038c8ac..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 "" } @@ -332,7 +365,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 { @@ -356,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: