From 3bbe533031ed90739827d3c34d712d910dbc553c Mon Sep 17 00:00:00 2001 From: creatang Date: Sat, 4 Apr 2026 17:42:17 +0800 Subject: [PATCH 1/2] refactor(tui): componentize command menu/activity/filepicker/progress --- go.mod | 2 + go.sum | 4 + internal/tui/app.go | 36 +++- internal/tui/command_menu.go | 208 +++++++++++++++++++++ internal/tui/commands.go | 100 ++++++----- internal/tui/commands_test.go | 8 +- internal/tui/copy_code_test.go | 8 +- internal/tui/input_features.go | 135 +++++++++++--- internal/tui/input_features_test.go | 12 +- internal/tui/markdown_renderer_test.go | 8 +- internal/tui/state.go | 101 ++++++++--- internal/tui/styles.go | 49 ++--- internal/tui/update.go | 238 ++++++++++++++++++++++--- internal/tui/update_test.go | 70 ++++---- internal/tui/view.go | 104 ++++------- 15 files changed, 819 insertions(+), 264 deletions(-) create mode 100644 internal/tui/command_menu.go diff --git a/go.mod b/go.mod index 40c31e10..671203ae 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/glamour v1.0.0 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect @@ -26,6 +27,7 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect diff --git a/go.sum b/go.sum index 8d964625..2c5bd376 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= @@ -46,6 +48,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= diff --git a/internal/tui/app.go b/internal/tui/app.go index 674d2167..2fdfaf81 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -4,8 +4,10 @@ import ( "fmt" "time" + "github.com/charmbracelet/bubbles/filepicker" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" @@ -26,9 +28,14 @@ type App struct { help help.Model spinner spinner.Model sessions list.Model + commandMenu list.Model + commandMenuMeta commandMenuMeta providerPicker list.Model modelPicker list.Model + fileBrowser filepicker.Model + progress progress.Model transcript viewport.Model + activity viewport.Model input textarea.Model markdownRenderer markdownContentRenderer codeCopyBlocks map[int]string @@ -44,6 +51,9 @@ type App struct { fileCandidates []string modelRefreshID string focus panel + runProgressValue float64 + runProgressKnown bool + runProgressLabel string width int height int styles styles @@ -110,6 +120,20 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime h := help.New() h.ShowAll = false + commandMenu := newCommandMenuModel(uiStyles) + + fileBrowser := filepicker.New() + fileBrowser.SetHeight(10) + fileBrowser.AutoHeight = false + fileBrowser.ShowPermissions = false + fileBrowser.ShowSize = false + fileBrowser.FileAllowed = true + fileBrowser.DirAllowed = true + fileBrowser.CurrentDirectory = cfg.Workdir + + progressBar := progress.New(progress.WithDefaultGradient(), progress.WithoutPercentage()) + progressBar.Width = 22 + app := App{ state: UIState{ StatusText: statusReady, @@ -126,9 +150,13 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime help: h, spinner: spin, sessions: sessionList, - providerPicker: newProviderPicker(nil), - modelPicker: newModelPicker(nil), + commandMenu: commandMenu, + providerPicker: newSelectionPickerItems(nil), + modelPicker: newSelectionPickerItems(nil), + fileBrowser: fileBrowser, + progress: progressBar, transcript: viewport.New(0, 0), + activity: viewport.New(0, 0), input: input, markdownRenderer: markdownRenderer, codeCopyBlocks: make(map[int]string), @@ -162,7 +190,9 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime if err := app.refreshFileCandidates(); err != nil { return App{}, err } - app.resizeComponents() + app.applyComponentLayout(true) + app.refreshCommandMenu() + app.rebuildActivity() return app, nil } diff --git a/internal/tui/command_menu.go b/internal/tui/command_menu.go new file mode 100644 index 00000000..b05ae8c9 --- /dev/null +++ b/internal/tui/command_menu.go @@ -0,0 +1,208 @@ +package tui + +import ( + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +const ( + maxCommandMenuRows = 6 + commandMenuBrowse = "@ browse files..." +) + +func (a *App) refreshCommandMenu() { + input := a.input.Value() + if a.state.ActivePicker != pickerNone { + a.commandMenu.SetItems(nil) + a.commandMenuMeta = commandMenuMeta{} + return + } + + items, meta := a.buildCommandMenuItems(input, a.transcript.Width) + if len(items) == 0 { + a.commandMenu.SetItems(nil) + a.commandMenuMeta = commandMenuMeta{} + return + } + + selectedTitle := "" + if selected, ok := a.commandMenu.SelectedItem().(commandMenuItem); ok { + selectedTitle = selected.title + } + + listItems := make([]list.Item, 0, len(items)) + selectedIndex := 0 + for index, item := range items { + listItems = append(listItems, item) + if selectedTitle != "" && strings.EqualFold(item.title, selectedTitle) { + selectedIndex = index + } + } + + a.commandMenu.SetItems(listItems) + a.commandMenu.Select(selectedIndex) + a.commandMenuMeta = meta + a.resizeCommandMenu() +} + +func (a *App) resizeCommandMenu() { + width := max(24, a.transcript.Width) + rows := clamp(len(a.commandMenu.Items()), 0, maxCommandMenuRows) + a.commandMenu.SetSize(max(16, width-4), max(1, rows)) +} + +func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, commandMenuMeta) { + if suggestions := a.fileMenuSuggestions(input); len(suggestions) > 0 { + return suggestions, commandMenuMeta{Title: fileMenuTitle} + } + + trimmed := strings.TrimSpace(input) + if isWorkspaceCommandInput(trimmed) { + replacement := trimmed + item := commandMenuItem{ + title: workspaceCommandUsage, + description: trimMiddle(a.state.CurrentWorkdir, max(24, width-28)), + highlight: true, + replacement: replacement, + } + if trimmed == workspaceCommandPrefix { + start, end, _, _ := tokenRange(input, tokenSelectorFirst) + item.replacement = workspaceCommandPrefix + " " + item.useReplaceRange = true + item.replaceStart = start + item.replaceEnd = end + } + return []commandMenuItem{item}, commandMenuMeta{Title: shellMenuTitle} + } + + suggestions := a.matchingSlashCommands(trimmed) + if len(suggestions) == 0 { + return nil, commandMenuMeta{} + } + + start, end, _, _ := tokenRange(input, tokenSelectorFirst) + items := make([]commandMenuItem, 0, len(suggestions)) + for _, suggestion := range suggestions { + items = append(items, commandMenuItem{ + title: suggestion.Command.Usage, + description: suggestion.Command.Description, + filter: suggestion.Command.Usage + " " + suggestion.Command.Description, + highlight: suggestion.Match, + replacement: suggestion.Command.Usage, + useReplaceRange: true, + replaceStart: start, + replaceEnd: end, + }) + } + return items, commandMenuMeta{Title: commandMenuTitle} +} + +func (a App) fileMenuSuggestions(input string) []commandMenuItem { + start, end, query, suggestions, ok := a.resolveFileReferenceSuggestions(input) + if !ok { + return nil + } + if len(suggestions) == 0 && query != "" { + return nil + } + + items := make([]commandMenuItem, 0, len(suggestions)+1) + if query == "" { + items = append(items, commandMenuItem{ + title: commandMenuBrowse, + description: "open workspace file browser", + filter: commandMenuBrowse, + highlight: true, + openFileBrowser: true, + }) + } + + for index, suggestion := range suggestions { + entry := "@" + suggestion + items = append(items, commandMenuItem{ + title: entry, + description: "workspace file reference", + filter: entry, + highlight: index == 0 && query != "", + replacement: entry, + useReplaceRange: true, + replaceStart: start, + replaceEnd: end, + }) + } + return items +} + +func (a App) commandMenuHasSuggestions() bool { + return len(a.commandMenu.Items()) > 0 +} + +func (a *App) applySelectedCommandSuggestion() bool { + if !a.commandMenuHasSuggestions() { + return false + } + item, ok := a.commandMenu.SelectedItem().(commandMenuItem) + if !ok { + return false + } + if item.openFileBrowser { + a.openFileBrowser() + return true + } + + current := a.input.Value() + next := current + if item.useReplaceRange { + if item.replaceStart < 0 || item.replaceEnd < item.replaceStart || item.replaceEnd > len(current) { + return false + } + next = current[:item.replaceStart] + item.replacement + current[item.replaceEnd:] + } else if strings.TrimSpace(item.replacement) != "" { + next = item.replacement + } + + if next == current { + return false + } + + a.input.SetValue(next) + a.state.InputText = next + a.normalizeComposerHeight() + a.applyComponentLayout(false) + a.refreshCommandMenu() + return true +} + +func (a *App) updateCommandMenuSelection(msg tea.KeyMsg) (tea.Cmd, bool) { + if !a.commandMenuHasSuggestions() { + return nil, false + } + + switch msg.Type { + case tea.KeyUp, tea.KeyDown, tea.KeyPgUp, tea.KeyPgDown, tea.KeyHome, tea.KeyEnd: + var cmd tea.Cmd + a.commandMenu, cmd = a.commandMenu.Update(msg) + return cmd, true + default: + return nil, false + } +} + +func (a *App) openFileBrowser() { + workdir := strings.TrimSpace(a.state.CurrentWorkdir) + if workdir == "" { + return + } + + absolute, err := filepath.Abs(workdir) + if err == nil { + a.fileBrowser.CurrentDirectory = absolute + } + a.state.ActivePicker = pickerFile + a.state.StatusText = statusBrowseFile + a.input.Blur() + a.applyComponentLayout(true) +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 74a7ae05..0c1fb4df 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -30,11 +30,13 @@ const ( slashUsageModel = "/model" slashUsageWorkdir = "/cwd" - commandMenuTitle = "Commands" + commandMenuTitle = "Suggestions" providerPickerTitle = "Select Provider" providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" modelPickerTitle = "Select Model" modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + filePickerTitle = "Browse Files" + filePickerSubtitle = "Navigate folders, Enter choose file, Esc cancel" sidebarTitle = "Sessions" sidebarFilterHint = "Type / to search" @@ -62,14 +64,14 @@ const ( statusCommandDone = "Command finished" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" + statusBrowseFile = "Browse workspace files" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" focusLabelActivity = "Activity" focusLabelComposer = "Composer" - activityPreviewEntries = 3 - maxActivityEntries = 64 + maxActivityEntries = 64 messageTagUser = "[ YOU ]" messageTagAgent = "[ NEO ]" @@ -132,33 +134,56 @@ func newSelectionPicker(items []list.Item) list.Model { return picker } -func newProviderPicker(items []provider.ProviderCatalogItem) list.Model { +func newCommandMenuModel(uiStyles styles) list.Model { + delegate := commandMenuDelegate{styles: uiStyles} + menu := list.New([]list.Item{}, delegate, 0, 0) + menu.Title = "" + menu.SetShowTitle(false) + menu.SetShowHelp(false) + menu.SetShowStatusBar(false) + menu.SetShowPagination(false) + menu.SetShowFilter(false) + menu.SetFilteringEnabled(false) + menu.DisableQuitKeybindings() + return menu +} + +func newSelectionPickerItems(items []selectionItem) list.Model { listItems := make([]list.Item, 0, len(items)) for _, item := range items { - listItems = append(listItems, providerItem{ + listItems = append(listItems, item) + } + return newSelectionPicker(listItems) +} + +func mapProviderItems(items []provider.ProviderCatalogItem) []selectionItem { + mapped := make([]selectionItem, 0, len(items)) + for _, item := range items { + mapped = append(mapped, selectionItem{ id: item.ID, name: item.Name, description: item.Description, }) } - return newSelectionPicker(listItems) + return mapped } -func newModelPicker(models []provider.ModelDescriptor) list.Model { - items := make([]list.Item, 0, len(models)) +func mapModelItems(models []provider.ModelDescriptor) []selectionItem { + mapped := make([]selectionItem, 0, len(models)) for _, option := range models { - items = append(items, modelItem{ + mapped = append(mapped, selectionItem{ id: option.ID, name: option.Name, description: option.Description, }) } - return newSelectionPicker(items) + return mapped } -func replacePickerItems(current list.Model, next list.Model) list.Model { +func replacePickerItems(current *list.Model, items []selectionItem) { + next := newSelectionPickerItems(items) next.SetSize(current.Width(), current.Height()) - return next + *current = next } func (a *App) refreshProviderPicker() error { @@ -167,8 +192,8 @@ func (a *App) refreshProviderPicker() error { return err } - a.providerPicker = replacePickerItems(a.providerPicker, newProviderPicker(items)) - a.selectCurrentProvider(a.state.CurrentProvider) + replacePickerItems(&a.providerPicker, mapProviderItems(items)) + selectPickerItemByID(&a.providerPicker, a.state.CurrentProvider) return nil } @@ -178,23 +203,24 @@ func (a *App) refreshModelPicker() error { return err } - a.modelPicker = replacePickerItems(a.modelPicker, newModelPicker(models)) - a.selectCurrentModel(a.state.CurrentModel) + replacePickerItems(&a.modelPicker, mapModelItems(models)) + selectPickerItemByID(&a.modelPicker, a.state.CurrentModel) return nil } func (a *App) openProviderPicker() { - a.state.ActivePicker = pickerProvider - a.state.StatusText = statusChooseProvider - a.input.Blur() - a.selectCurrentProvider(a.state.CurrentProvider) + a.openPicker(pickerProvider, statusChooseProvider, &a.providerPicker, a.state.CurrentProvider) } func (a *App) openModelPicker() { - a.state.ActivePicker = pickerModel - a.state.StatusText = statusChooseModel + a.openPicker(pickerModel, statusChooseModel, &a.modelPicker, a.state.CurrentModel) +} + +func (a *App) openPicker(mode pickerMode, statusText string, picker *list.Model, selectedID string) { + a.state.ActivePicker = mode + a.state.StatusText = statusText a.input.Blur() - a.selectCurrentModel(a.state.CurrentModel) + selectPickerItemByID(picker, selectedID) } func (a *App) closePicker() { @@ -203,32 +229,26 @@ func (a *App) closePicker() { a.applyFocus() } -func (a *App) selectCurrentProvider(providerID string) { - items := a.providerPicker.Items() +func selectPickerItemByID(picker *list.Model, selectedID string) { + items := picker.Items() for idx, item := range items { - candidate, ok := item.(providerItem) - if ok && strings.EqualFold(candidate.id, providerID) { - a.providerPicker.Select(idx) + candidate, ok := item.(selectionItem) + if ok && strings.EqualFold(candidate.id, selectedID) { + picker.Select(idx) return } } if len(items) > 0 { - a.providerPicker.Select(0) + picker.Select(0) } } +func (a *App) selectCurrentProvider(providerID string) { + selectPickerItemByID(&a.providerPicker, providerID) +} + func (a *App) selectCurrentModel(modelID string) { - items := a.modelPicker.Items() - for idx, item := range items { - candidate, ok := item.(modelItem) - if ok && strings.EqualFold(candidate.id, modelID) { - a.modelPicker.Select(idx) - return - } - } - if len(items) > 0 { - a.modelPicker.Select(0) - } + selectPickerItemByID(&a.modelPicker, modelID) } func (a App) matchingSlashCommands(input string) []commandSuggestion { diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index a47b8669..58dfae53 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -243,7 +243,7 @@ func TestCommandHelperFunctions(t *testing.T) { }) t.Run("sanitize workspace output decodes utf16le launcher errors", func(t *testing.T) { - text := "适用于 Linux 的 Windows 子系统没有已安装的分发版。\r\n请运行 wsl.exe --list --online" + text := "This app needs WSL installed.\r\nRun wsl.exe --list --online" encoded := utf16.Encode([]rune(text)) raw := make([]byte, 0, len(encoded)*2) for _, word := range encoded { @@ -253,7 +253,7 @@ func TestCommandHelperFunctions(t *testing.T) { } got := sanitizeWorkspaceOutput(raw) - for _, want := range []string{"适用于 Linux", "Windows 子系统", "wsl.exe --list --online"} { + for _, want := range []string{"This app needs WSL installed.", "Run wsl.exe --list --online"} { if !strings.Contains(got, want) { t.Fatalf("expected decoded output to contain %q, got %q", want, got) } @@ -261,7 +261,7 @@ func TestCommandHelperFunctions(t *testing.T) { }) 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" + text := "Access denied.\r\nError code: Bash/Service/CreateInstance/E_ACCESSDENIED" encoded := utf16.Encode([]rune(text)) raw := make([]byte, 0, len(encoded)*2) for _, word := range encoded { @@ -271,7 +271,7 @@ func TestCommandHelperFunctions(t *testing.T) { } got := decodeWorkspaceOutput(raw) - for _, want := range []string{"拒绝访问", "错误代码", "E_ACCESSDENIED"} { + for _, want := range []string{"Access denied.", "Error code", "E_ACCESSDENIED"} { if !strings.Contains(got, want) { t.Fatalf("expected decoded utf16 output to contain %q, got %q", want, got) } diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go index 4c77ca5d..ae9e4305 100644 --- a/internal/tui/copy_code_test.go +++ b/internal/tui/copy_code_test.go @@ -36,7 +36,7 @@ func TestExtractFencedCodeBlocksWithoutLanguageKeepsFirstLine(t *testing.T) { } func TestExtractFencedCodeBlocksFromIndentedMarkdown(t *testing.T) { - content := "说明:\n\n package main\n import \"fmt\"\n\n结尾。" + content := "intro\n\n package main\n import \"fmt\"\n\nending" blocks := extractFencedCodeBlocks(content) if len(blocks) != 1 { t.Fatalf("expected 1 code block from indented markdown, got %d", len(blocks)) @@ -107,7 +107,7 @@ func TestRenderMessageBlockWithCopyAddsButtonsForIndentedCode(t *testing.T) { t.Fatalf("New() error = %v", err) } - content := "说明:\n\n package main\n import \"fmt\"" + content := "璇存槑锛歕n\n package main\n import \"fmt\"" rendered, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, content), 80, 1) if !strings.Contains(stripANSI(rendered), "[Copy code #1]") { t.Fatalf("expected copy button for indented markdown code, got %q", rendered) @@ -139,7 +139,7 @@ func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { app.activeMessages = []provider.Message{ {Role: roleAssistant, Content: "```go\nfmt.Println(1)\n```"}, } - app.resizeComponents() + app.applyComponentLayout(true) app.rebuildTranscript() x, y, _, _ := app.transcriptBounds() @@ -226,7 +226,7 @@ func TestTranscriptMouseCopyFailureSetsError(t *testing.T) { app.activeMessages = []provider.Message{ {Role: roleAssistant, Content: "```txt\nhello\n```"}, } - app.resizeComponents() + app.applyComponentLayout(true) app.rebuildTranscript() x, y, _, _ := app.transcriptBounds() diff --git a/internal/tui/input_features.go b/internal/tui/input_features.go index e5f11ac2..154f195f 100644 --- a/internal/tui/input_features.go +++ b/internal/tui/input_features.go @@ -36,6 +36,13 @@ type workspaceCommandResultMsg struct { err error } +type tokenSelector int + +const ( + tokenSelectorFirst tokenSelector = iota + tokenSelectorLast +) + var workspaceCommandExecutor = defaultWorkspaceCommandExecutor var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) @@ -250,6 +257,12 @@ func (a *App) refreshFileCandidates() error { return err } a.fileCandidates = candidates + if workdir := strings.TrimSpace(a.state.CurrentWorkdir); workdir != "" { + if absolute, absErr := filepath.Abs(workdir); absErr == nil { + a.fileBrowser.CurrentDirectory = absolute + } + } + a.refreshCommandMenu() return nil } @@ -296,20 +309,24 @@ func collectWorkspaceFiles(root string, limit int) ([]string, error) { return candidates, nil } -func (a App) matchingFileReferences(input string) []string { - _, _, token, ok := currentReferenceToken(input) +func (a App) resolveFileReferenceSuggestions(input string) (start int, end int, query string, suggestions []string, ok bool) { + start, end, token, ok := currentReferenceToken(input) if !ok { - return nil + return 0, 0, "", nil, false } - query := strings.ToLower(strings.TrimPrefix(token, fileReferencePrefix)) - if len(a.fileCandidates) == 0 { + query = strings.ToLower(strings.TrimPrefix(token, fileReferencePrefix)) + suggestions = collectFileSuggestionMatches(query, a.fileCandidates, maxFileSuggestions) + return start, end, query, suggestions, true +} + +func collectFileSuggestionMatches(query string, candidates []string, limit int) []string { + if len(candidates) == 0 || limit <= 0 { return nil } - prefixMatches := make([]string, 0, maxFileSuggestions) containsMatches := make([]string, 0, maxFileSuggestions) - for _, candidate := range a.fileCandidates { + for _, candidate := range candidates { lower := strings.ToLower(candidate) switch { case query == "" || strings.HasPrefix(lower, query): @@ -323,47 +340,111 @@ func (a App) matchingFileReferences(input string) []string { } out := append(prefixMatches, containsMatches...) - if len(out) > maxFileSuggestions { - out = out[:maxFileSuggestions] + if len(out) > limit { + out = out[:limit] } return out } -func currentReferenceToken(input string) (start int, end int, token string, ok bool) { +func tokenRange(input string, selector tokenSelector) (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 { + switch selector { + case tokenSelectorFirst: start = 0 - } else { - start++ + for start < len(input) { + switch input[start] { + case ' ', '\t', '\r', '\n': + start++ + default: + goto parse + } + } + return 0, 0, "", false + case tokenSelectorLast: + end = len(input) + start = strings.LastIndexAny(input, " \t\r\n") + if start < 0 { + start = 0 + } else { + start++ + } + if start >= end { + return 0, 0, "", false + } + token = input[start:end] + return start, end, token, true + default: + return 0, 0, "", false } +parse: + end = start + for end < len(input) { + switch input[end] { + case ' ', '\t', '\r', '\n': + token = input[start:end] + return start, end, token, true + default: + end++ + } + } token = input[start:end] + return start, end, token, true +} + +func currentReferenceToken(input string) (start int, end int, token string, ok bool) { + start, end, token, ok = tokenRange(input, tokenSelectorLast) + if !ok { + return 0, 0, "", false + } 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 +func (a *App) applyFileReference(path string) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("file path is empty") + } + + resolved := filepath.ToSlash(path) + if workdir := strings.TrimSpace(a.state.CurrentWorkdir); workdir != "" { + base, errBase := filepath.Abs(workdir) + target, errTarget := filepath.Abs(path) + if errBase == nil && errTarget == nil { + if rel, errRel := filepath.Rel(base, target); errRel == nil && !strings.HasPrefix(rel, "..") { + resolved = filepath.ToSlash(rel) + } else { + resolved = filepath.ToSlash(target) + } + } } + resolved = strings.TrimPrefix(resolved, "./") + reference := fileReferencePrefix + resolved - start, end, _, ok := currentReferenceToken(a.input.Value()) - if !ok { - return false + current := a.input.Value() + if start, end, _, ok := currentReferenceToken(current); ok { + current = current[:start] + reference + current[end:] + } else if strings.TrimSpace(current) == "" { + current = reference + } else { + separator := " " + if strings.HasSuffix(current, " ") || strings.HasSuffix(current, "\t") { + separator = "" + } + current = current + separator + reference } - next := a.input.Value()[:start] + fileReferencePrefix + suggestions[0] + a.input.Value()[end:] - a.input.SetValue(next) - a.state.InputText = next + a.input.SetValue(current) + a.state.InputText = current a.normalizeComposerHeight() - a.resizeComposerLayout() - return true + a.applyComponentLayout(false) + a.refreshCommandMenu() + a.state.StatusText = fmt.Sprintf("[System] Added file reference %s.", reference) + return nil } diff --git a/internal/tui/input_features_test.go b/internal/tui/input_features_test.go index eaac62a6..24ca63ec 100644 --- a/internal/tui/input_features_test.go +++ b/internal/tui/input_features_test.go @@ -174,12 +174,13 @@ func TestWorkspaceFileHelpers(t *testing.T) { app.fileCandidates = []string{"README.md", "internal/tui/update.go", "internal/tui/view.go"} app.input.SetValue("inspect @internal/tui/upd") app.state.InputText = app.input.Value() + app.refreshCommandMenu() - suggestions := app.matchingFileReferences(app.input.Value()) - if len(suggestions) == 0 || suggestions[0] != "internal/tui/update.go" { + _, _, _, suggestions, ok := app.resolveFileReferenceSuggestions(app.input.Value()) + if !ok || len(suggestions) == 0 || suggestions[0] != "internal/tui/update.go" { t.Fatalf("unexpected suggestions %v", suggestions) } - if !app.applyTopFileSuggestion() { + if !app.applySelectedCommandSuggestion() { t.Fatalf("expected top suggestion to be applied") } if app.state.InputText != "inspect @internal/tui/update.go" { @@ -188,8 +189,9 @@ func TestWorkspaceFileHelpers(t *testing.T) { app.input.SetValue("inspect plain-text") app.state.InputText = app.input.Value() - if app.applyTopFileSuggestion() { - t.Fatalf("expected applyTopFileSuggestion to fail without @ token") + app.refreshCommandMenu() + if app.applySelectedCommandSuggestion() { + t.Fatalf("expected applySelectedCommandSuggestion to fail without @ token") } }) } diff --git a/internal/tui/markdown_renderer_test.go b/internal/tui/markdown_renderer_test.go index 1dbc4f72..ad04a8a1 100644 --- a/internal/tui/markdown_renderer_test.go +++ b/internal/tui/markdown_renderer_test.go @@ -80,20 +80,20 @@ func TestMarkdownRendererCachesByWidth(t *testing.T) { } } -func TestMarkdownRendererPreservesChineseText(t *testing.T) { +func TestMarkdownRendererPreservesContent(t *testing.T) { rendererAny, err := newMarkdownRenderer() if err != nil { t.Fatalf("newMarkdownRenderer() error = %v", err) } renderer := rendererAny.(*glamourMarkdownRenderer) - output, err := renderer.Render("中文标题\n\n- 第一项\n- 第二项", 40) + output, err := renderer.Render("Title\n\n- first item\n- second item", 40) if err != nil { t.Fatalf("Render() error = %v", err) } visible := markdownTestANSIPattern.ReplaceAllString(output, "") - if !strings.Contains(visible, "中文标题") || !strings.Contains(visible, "第一项") || !strings.Contains(visible, "第二项") { - t.Fatalf("expected chinese markdown content to be preserved, got %q", visible) + if !strings.Contains(visible, "Title") || !strings.Contains(visible, "first item") || !strings.Contains(visible, "second item") { + t.Fatalf("expected markdown content to be preserved, got %q", visible) } } diff --git a/internal/tui/state.go b/internal/tui/state.go index 2bd56b35..60c52dc2 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -28,6 +28,7 @@ const ( pickerNone pickerMode = iota pickerProvider pickerModel + pickerFile ) type UIState struct { @@ -56,49 +57,105 @@ type activityEntry struct { IsError bool } -type sessionItem struct { - Summary agentruntime.SessionSummary - Active bool +type commandMenuMeta struct { + Title string } -func (s sessionItem) FilterValue() string { - return strings.ToLower(s.Summary.Title) +type commandMenuItem struct { + title string + description string + filter string + highlight bool + replacement string + useReplaceRange bool + replaceStart int + replaceEnd int + openFileBrowser bool } -type modelItem struct { - id string - name string - description string +func (c commandMenuItem) Title() string { + return c.title } -func (m modelItem) Title() string { - return m.name +func (c commandMenuItem) Description() string { + return c.description } -func (m modelItem) Description() string { - return m.description +func (c commandMenuItem) FilterValue() string { + base := strings.TrimSpace(c.filter) + if base != "" { + return strings.ToLower(base) + } + return strings.ToLower(c.title + " " + c.description) } -func (m modelItem) FilterValue() string { - return strings.ToLower(m.id + " " + m.name + " " + m.description) +type commandMenuDelegate struct { + styles styles +} + +func (d commandMenuDelegate) Height() int { + return 1 +} + +func (d commandMenuDelegate) Spacing() int { + return 0 +} + +func (d commandMenuDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + return nil +} + +func (d commandMenuDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + entry, ok := item.(commandMenuItem) + if !ok { + return + } + + contentWidth := max(12, m.Width()-2) + usageStyle := d.styles.commandUsage + if entry.highlight || index == m.Index() { + usageStyle = d.styles.commandUsageMatch + } + + line := usageStyle.Render(entry.title) + if description := strings.TrimSpace(entry.description); description != "" { + descWidth := max(8, contentWidth-lipgloss.Width(entry.title)-2) + line = lipgloss.JoinHorizontal( + lipgloss.Top, + line, + lipgloss.NewStyle().Width(2).Render(""), + d.styles.commandDesc.Render(trimMiddle(description, descWidth)), + ) + } + + fmt.Fprint(w, lipgloss.NewStyle().Width(contentWidth).Render(line)) +} + +type sessionItem struct { + Summary agentruntime.SessionSummary + Active bool +} + +func (s sessionItem) FilterValue() string { + return strings.ToLower(s.Summary.Title) } -type providerItem struct { +type selectionItem struct { id string name string description string } -func (p providerItem) Title() string { - return p.name +func (s selectionItem) Title() string { + return s.name } -func (p providerItem) Description() string { - return p.description +func (s selectionItem) Description() string { + return s.description } -func (p providerItem) FilterValue() string { - return strings.ToLower(p.id + " " + p.name + " " + p.description) +func (s selectionItem) FilterValue() string { + return strings.ToLower(s.id + " " + s.name + " " + s.description) } type sessionDelegate struct { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 5dd5f2f4..ed67013a 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -153,35 +153,47 @@ func newStyles() styles { messageToolTag: tagStyle(colorSuccess), messageBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - Background(lipgloss.Color(colorPanel)). - Padding(0, 1), + UnsetBackground(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorBorder)). + Padding(0, 0), messageUserBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - Background(lipgloss.Color("#12202D")). - Padding(0, 1), + UnsetBackground(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorUser)). + Padding(0, 0), messageToolBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSuccess)). - Background(lipgloss.Color("#0F1B1C")). - Padding(0, 1), + UnsetBackground(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorSuccess)). + Padding(0, 0), inlineNotice: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). - Background(lipgloss.Color(colorPanel)). - Padding(0, 1). + UnsetBackground(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorBorder)). + Padding(0, 0). Italic(true), inlineError: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorError)). - Background(lipgloss.Color("#21131A")). - Padding(0, 1). + UnsetBackground(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorError)). + Padding(0, 0). Bold(true), inlineSystem: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). - Background(lipgloss.Color(colorPanel)). - Padding(0, 1), + UnsetBackground(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorBorder)). + Padding(0, 0), codeBlock: lipgloss.NewStyle(). MarginLeft(1). - Padding(0, 1). - Background(lipgloss.Color(colorCode)). - BorderLeft(true). + Padding(0, 0). + UnsetBackground(). + Border(lipgloss.NormalBorder()). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(colorPrimary)), codeText: lipgloss.NewStyle(). @@ -348,10 +360,3 @@ func clamp(value int, minValue int, maxValue int) int { } return value } - -func max(a int, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/tui/update.go b/internal/tui/update.go index 74b8601f..a030133c 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -64,7 +64,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: a.width = typed.Width a.height = typed.Height - a.resizeComponents() + a.applyComponentLayout(true) return a, tea.Batch(cmds...) case RuntimeMsg: transcriptDirty := a.handleRuntimeEvent(typed.Event) @@ -77,6 +77,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) case RuntimeClosedMsg: a.state.IsAgentRunning = false + a.clearRunProgress() if strings.TrimSpace(a.state.StatusText) == "" { a.state.StatusText = statusRuntimeClosed } @@ -84,6 +85,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case runFinishedMsg: if typed.err != nil { a.state.IsAgentRunning = false + a.clearRunProgress() a.state.StreamingReply = false a.state.CurrentTool = "" if errors.Is(typed.err, context.Canceled) { @@ -94,6 +96,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.StatusText = typed.err.Error() } } + if !a.state.IsAgentRunning { + a.clearRunProgress() + } _ = a.refreshSessions() a.syncActiveSessionTitle() return a, tea.Batch(cmds...) @@ -109,10 +114,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } - a.modelPicker = replacePickerItems(a.modelPicker, newModelPicker(typed.models)) + replacePickerItems(&a.modelPicker, mapModelItems(typed.models)) cfg := a.configManager.Get() a.syncConfigState(cfg) - a.selectCurrentModel(cfg.CurrentModel) + selectPickerItemByID(&a.modelPicker, cfg.CurrentModel) return a, tea.Batch(cmds...) case localCommandResultMsg: if typed.err != nil { @@ -189,6 +194,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.handleTranscriptMouse(typed) { return a, tea.Batch(cmds...) } + if a.handleActivityMouse(typed) { + return a, tea.Batch(cmds...) + } if a.handleInputMouse(typed) { return a, tea.Batch(cmds...) } @@ -199,7 +207,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key.Matches(typed, a.keys.ToggleHelp) { a.state.ShowHelp = !a.state.ShowHelp a.help.ShowAll = a.state.ShowHelp - a.resizeComponents() + a.applyComponentLayout(true) return a, tea.Batch(cmds...) } if a.state.IsAgentRunning && key.Matches(typed, a.keys.CancelAgent) { @@ -211,8 +219,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.state.ActivePicker != pickerNone { return a.updatePicker(typed) } + if a.focus == panelInput { + if cmd, handled := a.updateCommandMenuSelection(typed); handled { + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + } + } if a.focus == panelInput && key.Matches(typed, a.keys.NextPanel) { - if a.applyTopFileSuggestion() { + if a.applySelectedCommandSuggestion() { return a, tea.Batch(cmds...) } if a.shouldHandleTabAsInput(typed) { @@ -258,6 +274,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case panelTranscript: a.handleViewportKeys(&a.transcript, typed) return a, tea.Batch(cmds...) + case panelActivity: + a.handleViewportKeys(&a.activity, typed) + return a, tea.Batch(cmds...) case panelInput: return a.updateInputPanel(msg, typed, cmds) } @@ -283,7 +302,8 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.input.Reset() a.state.InputText = "" - a.resizeComponents() + a.applyComponentLayout(true) + a.refreshCommandMenu() a.resetPasteHeuristics() if handled, cmd := a.handleImmediateSlashCommand(input); handled { @@ -336,7 +356,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.appendActivity("command", "Invalid workspace command", err.Error(), true) return a, tea.Batch(cmds...) } - a.activities = nil + a.clearActivities() a.state.StatusText = statusRunningCommand a.state.ExecutionError = "" a.appendActivity("command", "Running command", command, false) @@ -344,7 +364,8 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } - a.activities = nil + a.clearActivities() + a.clearRunProgress() a.state.IsAgentRunning = true a.state.StreamingReply = false a.state.ExecutionError = "" @@ -373,7 +394,8 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.InputText = a.input.Value() a.noteInputEdit(before, a.state.InputText, effectiveTyped, now) a.normalizeComposerHeight() - a.resizeComposerLayout() + a.applyComponentLayout(false) + a.refreshCommandMenu() cmds = append(cmds, cmd) return a, tea.Batch(cmds...) } @@ -479,14 +501,14 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case msg.String() == "enter": switch a.state.ActivePicker { case pickerProvider: - item, ok := a.providerPicker.SelectedItem().(providerItem) + item, ok := a.providerPicker.SelectedItem().(selectionItem) a.closePicker() if !ok { return a, nil } return a, runProviderSelection(a.providerSvc, item.name) case pickerModel: - item, ok := a.modelPicker.SelectedItem().(modelItem) + item, ok := a.modelPicker.SelectedItem().(selectionItem) a.closePicker() if !ok { return a, nil @@ -501,6 +523,21 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerPicker, cmd = a.providerPicker.Update(msg) case pickerModel: a.modelPicker, cmd = a.modelPicker.Update(msg) + case pickerFile: + a.fileBrowser, cmd = a.fileBrowser.Update(msg) + if didSelect, path := a.fileBrowser.DidSelectFile(msg); didSelect { + a.closePicker() + if err := a.applyFileReference(path); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("workspace", "Failed to apply file reference", err.Error(), true) + return a, cmd + } + return a, cmd + } + if disabled, path := a.fileBrowser.DidSelectDisabledFile(msg); disabled { + a.state.StatusText = fmt.Sprintf("[System] %s is not selectable.", filepath.Base(path)) + } } return a, cmd } @@ -538,7 +575,7 @@ func (a *App) refreshSessions() error { func (a *App) refreshMessages() error { if strings.TrimSpace(a.state.ActiveSessionID) == "" { a.activeMessages = nil - a.activities = nil + a.clearActivities() return nil } @@ -548,7 +585,7 @@ func (a *App) refreshMessages() error { } a.activeMessages = session.Messages - a.activities = nil + a.clearActivities() a.state.ActiveSessionTitle = session.Title a.state.CurrentWorkdir = selectSessionWorkdir(session.Workdir, a.configManager.Get().Workdir) return nil @@ -609,9 +646,11 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ExecutionError = "" + a.setRunProgress(0.15, "Queued") case agentruntime.EventToolCallThinking: if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { a.state.CurrentTool = payload + a.setRunProgress(0.35, "Planning") a.appendActivity("tool", "Planning tool call", payload, false) } case agentruntime.EventToolStart: @@ -619,11 +658,13 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { a.state.StreamingReply = false if payload, ok := event.Payload.(provider.ToolCall); ok { a.state.CurrentTool = payload.Name + a.setRunProgress(0.6, "Running tool") a.appendActivity("tool", "Running tool", payload.Name, false) } case agentruntime.EventToolResult: a.state.StreamingReply = false a.state.CurrentTool = "" + a.setRunProgress(0.8, "Integrating result") if payload, ok := event.Payload.(tools.ToolResult); ok { a.activeMessages = append(a.activeMessages, provider.Message{ Role: roleTool, @@ -643,6 +684,9 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { case agentruntime.EventAgentChunk: if payload, ok := event.Payload.(string); ok { a.appendAssistantChunk(payload) + if !a.runProgressKnown { + a.setRunProgress(0.72, "Generating") + } transcriptDirty = true } case agentruntime.EventToolChunk: @@ -654,6 +698,7 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { a.state.IsAgentRunning = false a.state.StreamingReply = false a.state.CurrentTool = "" + a.clearRunProgress() if strings.TrimSpace(a.state.ExecutionError) == "" { a.state.StatusText = statusReady } @@ -667,12 +712,14 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { a.state.CurrentTool = "" a.state.ExecutionError = "" a.state.StatusText = statusCanceled + a.clearRunProgress() a.appendActivity("run", "Canceled current run", "", false) case agentruntime.EventError: a.state.StatusText = statusError a.state.IsAgentRunning = false a.state.StreamingReply = false a.state.CurrentTool = "" + a.clearRunProgress() if payload, ok := event.Payload.(string); ok { a.state.ExecutionError = payload a.state.StatusText = payload @@ -681,6 +728,7 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { case agentruntime.EventProviderRetry: if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { a.state.StatusText = statusThinking + a.runProgressKnown = false a.appendActivity("provider", "Retrying provider call", payload, false) } } @@ -712,6 +760,7 @@ func (a *App) appendInlineMessage(role string, message string) { } func (a *App) appendActivity(kind string, title string, detail string, isError bool) { + previousCount := len(a.activities) title = strings.TrimSpace(title) detail = strings.TrimSpace(detail) if title == "" && detail == "" { @@ -732,6 +781,25 @@ func (a *App) appendActivity(kind string, title string, detail string, isError b if len(a.activities) > maxActivityEntries { a.activities = append([]activityEntry(nil), a.activities[len(a.activities)-maxActivityEntries:]...) } + a.syncActivityViewport(previousCount) +} + +func (a *App) clearActivities() { + previousCount := len(a.activities) + if previousCount == 0 { + return + } + a.activities = nil + a.syncActivityViewport(previousCount) +} + +func (a *App) syncActivityViewport(previousCount int) { + visibleBefore := previousCount > 0 + visibleNow := len(a.activities) > 0 + if visibleBefore != visibleNow { + a.applyComponentLayout(true) + } + a.rebuildActivity() } func (a *App) lastAssistantMatches(content string) bool { @@ -864,6 +932,64 @@ func (a App) inputBounds() (int, int, int, int) { return streamX, inputY, lay.rightWidth, inputHeight } +func (a App) activityBounds() (int, int, int, int) { + lay := a.computeLayout() + contentX := a.styles.doc.GetPaddingLeft() + contentY := a.styles.doc.GetPaddingTop() + headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + bodyY := contentY + headerHeight + + streamX := contentX + streamY := bodyY + if lay.stacked { + streamY += lay.sidebarHeight + } else { + streamX += lay.sidebarWidth + lay.bodyGap + } + + activityHeight := a.activityPreviewHeight() + if activityHeight <= 0 { + return streamX, streamY + a.transcript.Height, lay.rightWidth, 0 + } + return streamX, streamY + a.transcript.Height, lay.rightWidth, activityHeight +} + +func (a App) isMouseWithinActivity(msg tea.MouseMsg) bool { + x, y, width, height := a.activityBounds() + if width <= 0 || height <= 0 { + return false + } + return msg.X >= x && msg.X < x+width && msg.Y >= y && msg.Y < y+height +} + +func (a *App) handleActivityMouse(msg tea.MouseMsg) bool { + if len(a.activities) == 0 || !a.isMouseWithinActivity(msg) { + return false + } + if a.state.ActivePicker != pickerNone { + return false + } + + switch { + case msg.Button == tea.MouseButtonWheelUp && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelUp): + if a.focus != panelActivity { + a.focus = panelActivity + a.applyFocus() + } + a.activity.LineUp(mouseWheelStepLines) + return true + case msg.Button == tea.MouseButtonWheelDown && (msg.Action == tea.MouseActionPress || msg.Type == tea.MouseWheelDown): + if a.focus != panelActivity { + a.focus = panelActivity + a.applyFocus() + } + a.activity.LineDown(mouseWheelStepLines) + return true + default: + return false + } +} + func (a *App) handleInputMouse(msg tea.MouseMsg) bool { if !a.isMouseWithinInput(msg) { return false @@ -918,7 +1044,7 @@ func (a App) shouldHandleTabAsInput(typed tea.KeyMsg) bool { } func (a *App) focusNext() { - order := []panel{panelSessions, panelTranscript, panelInput} + order := []panel{panelSessions, panelTranscript, panelActivity, panelInput} current := 0 for i, item := range order { if item == a.focus { @@ -932,7 +1058,7 @@ func (a *App) focusNext() { } func (a *App) focusPrev() { - order := []panel{panelSessions, panelTranscript, panelInput} + order := []panel{panelSessions, panelTranscript, panelActivity, panelInput} current := 0 for i, item := range order { if item == a.focus { @@ -959,39 +1085,52 @@ 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 + prevActivityWidth := a.activity.Width + prevActivityHeight := a.activity.Height a.help.ShowAll = a.state.ShowHelp sidebarFrameWidth := a.styles.panelFocused.GetHorizontalFrameSize() sidebarFrameHeight := a.styles.panelFocused.GetVerticalFrameSize() sidebarBodyWidth := max(14, lay.sidebarWidth-sidebarFrameWidth) sidebarBodyHeight := max(4, lay.sidebarHeight-sidebarFrameHeight-lipgloss.Height(a.renderSidebarHeader(sidebarBodyWidth))) a.sessions.SetSize(sidebarBodyWidth, sidebarBodyHeight) + a.transcript.Width = max(24, lay.rightWidth) + a.resizeCommandMenu() menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) activityHeight := a.activityPreviewHeight() - a.transcript.Width = max(24, lay.rightWidth) a.input.SetWidth(a.composerInnerWidth(lay.rightWidth)) a.input.SetHeight(a.composerHeight()) promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) a.transcript.Height = max(6, lay.rightHeight-activityHeight-menuHeight-promptHeight) + + if activityHeight > 0 { + panelStyle := a.styles.panelFocused + frameHeight := panelStyle.GetVerticalFrameSize() + borderWidth := 2 + paddingWidth := panelStyle.GetHorizontalFrameSize() - borderWidth + panelWidth := max(1, lay.rightWidth-borderWidth) + bodyWidth := max(10, panelWidth-paddingWidth) + bodyHeight := max(1, activityHeight-frameHeight-1) + a.activity.Width = bodyWidth + a.activity.Height = bodyHeight + } else { + a.activity.Width = max(10, lay.rightWidth-4) + a.activity.Height = 0 + } + a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) + a.fileBrowser.SetHeight(max(6, clamp(lay.rightHeight-8, 8, 16))) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { a.rebuildTranscript() - return - } - if a.transcript.AtBottom() || a.state.IsAgentRunning { + } else if a.transcript.AtBottom() || a.state.IsAgentRunning { a.transcript.GotoBottom() } + if prevActivityWidth != a.activity.Width || prevActivityHeight != a.activity.Height { + a.rebuildActivity() + } } func (a App) composerBoxWidth(totalWidth int) int { @@ -1047,6 +1186,44 @@ func (a *App) rebuildTranscript() { } } +func (a *App) rebuildActivity() { + if len(a.activities) == 0 || a.activity.Height <= 0 { + a.activity.SetContent("") + a.activity.GotoTop() + return + } + + atBottom := a.activity.AtBottom() + width := max(12, a.activity.Width) + lines := make([]string, 0, len(a.activities)) + for _, entry := range a.activities { + lines = append(lines, a.renderActivityLine(entry, width)) + } + a.activity.SetContent(strings.Join(lines, "\n")) + if atBottom || a.focus != panelActivity { + a.activity.GotoBottom() + } +} + +func (a *App) setRunProgress(value float64, label string) { + a.runProgressKnown = true + switch { + case value < 0: + a.runProgressValue = 0 + case value > 1: + a.runProgressValue = 1 + default: + a.runProgressValue = value + } + a.runProgressLabel = strings.TrimSpace(label) +} + +func (a *App) clearRunProgress() { + a.runProgressKnown = false + a.runProgressValue = 0 + a.runProgressLabel = "" +} + func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) switch command { @@ -1068,6 +1245,8 @@ func (a App) currentStatusSnapshot() statusSnapshot { picker = "provider" case pickerModel: picker = "model" + case pickerFile: + picker = "file" } return statusSnapshot{ @@ -1089,10 +1268,11 @@ func (a *App) startDraftSession() { a.state.ActiveSessionID = "" a.state.ActiveSessionTitle = draftSessionTitle a.activeMessages = nil - a.activities = nil + a.clearActivities() a.state.StatusText = statusDraft a.state.ExecutionError = "" a.state.CurrentTool = "" + a.clearRunProgress() a.input.Reset() a.state.InputText = "" a.state.CurrentWorkdir = a.configManager.Get().Workdir @@ -1102,7 +1282,7 @@ func (a *App) startDraftSession() { } a.focus = panelInput a.applyFocus() - a.resizeComponents() + a.applyComponentLayout(true) a.rebuildTranscript() } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 9154a2a9..8ba83108 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -699,15 +699,18 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { t.Fatalf("expected model picker rendering") } app.state.ActivePicker = pickerNone + app.refreshCommandMenu() if app.renderCommandMenu(80) == "" { app.input.SetValue("/") app.state.InputText = "/" + app.refreshCommandMenu() if app.renderCommandMenu(80) == "" { t.Fatalf("expected slash command menu when input starts with slash") } } app.input.SetValue("/status") app.state.InputText = "/status" + app.refreshCommandMenu() if menu := app.renderCommandMenu(80); menu != "" { t.Fatalf("expected complete slash command to hide menu, got %q", menu) } @@ -751,7 +754,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { } app.input.SetValue("one") app.state.InputText = "one" - app.resizeComponents() + app.applyComponentLayout(true) if app.input.Height() != 1 { t.Fatalf("expected single-line composer height 1, got %d", app.input.Height()) } @@ -760,7 +763,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { } app.input.SetValue("one\ntwo") app.state.InputText = app.input.Value() - app.resizeComponents() + app.applyComponentLayout(true) if app.input.Height() != 2 { t.Fatalf("expected two-line composer height 2, got %d", app.input.Height()) } @@ -769,7 +772,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { } app.input.SetValue(strings.Join([]string{"1", "2", "3", "4", "5", "6"}, "\n")) app.state.InputText = app.input.Value() - app.resizeComponents() + app.applyComponentLayout(true) if app.input.Height() != composerMaxHeight { t.Fatalf("expected composer height capped at %d, got %d", composerMaxHeight, app.input.Height()) } @@ -788,18 +791,19 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if app.statusBadge("error: boom") == "" || app.statusBadge("running now") == "" { t.Fatalf("expected status badge variants") } - if app.renderMessageBlock(provider.Message{Role: roleError, Content: "boom"}, 80) == "" { + if rendered, _ := app.renderMessageBlockWithCopy(provider.Message{Role: roleError, Content: "boom"}, 80, 1); rendered == "" { t.Fatalf("expected error message block") } - if app.renderMessageBlock(provider.Message{ + if rendered, _ := app.renderMessageBlockWithCopy(provider.Message{ Role: roleAssistant, ToolCalls: []provider.ToolCall{ {Name: "filesystem_edit"}, }, - }, 80) == "" { + }, 80, 1); rendered == "" { t.Fatalf("expected tool call message block") } - if app.renderMessageContent("```go\nfmt.Println(\"x\")\n```", 80, app.styles.messageBody) == "" { + renderedCodeOnly, _ := app.renderMessageContentWithCopy("```go\nfmt.Println(\"x\")\n```", 80, app.styles.messageBody, 1) + if renderedCodeOnly == "" { t.Fatalf("expected code block rendering") } if app.computeLayout().contentWidth == 0 { @@ -845,7 +849,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { t.Fatalf("unexpected session item filter value") } - mItem := modelItem{name: "gpt-5.4", description: "Frontier"} + mItem := selectionItem{name: "gpt-5.4", description: "Frontier"} if mItem.Title() == "" || mItem.Description() == "" || mItem.FilterValue() == "" { t.Fatalf("expected model item helpers to return values") } @@ -858,7 +862,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { t.Fatalf("expected delegate update to return nil") } var buf bytes.Buffer - model := newModelPicker([]provider.ModelDescriptor{{ID: "gpt-4.1", Name: "gpt-4.1"}}) + model := newSelectionPickerItems(mapModelItems([]provider.ModelDescriptor{{ID: "gpt-4.1", Name: "gpt-4.1"}})) sessionList := []list.Item{sItem} listModel := list.New(sessionList, delegate, 30, 10) delegate.Render(&buf, listModel, 0, sItem) @@ -1018,7 +1022,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { app.applyFocus() app.input.SetValue("func main() {\n") app.state.InputText = app.input.Value() - app.resizeComponents() + app.applyComponentLayout(true) }, msg: tea.KeyMsg{Type: tea.KeyTab}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { @@ -1039,7 +1043,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { msg: tea.KeyMsg{Type: tea.KeyShiftTab}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() - if app.focus != panelTranscript { + if app.focus != panelActivity { t.Fatalf("expected focus to move backward, got %v", app.focus) } }, @@ -1113,7 +1117,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("inspect repo") app.state.InputText = "inspect repo" - app.resizeComponents() + app.applyComponentLayout(true) }, msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { @@ -1141,7 +1145,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("line1\nline2") app.state.InputText = app.input.Value() - app.resizeComponents() + app.applyComponentLayout(true) }, msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { @@ -1212,7 +1216,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue(" \n ") app.state.InputText = " \n " - app.resizeComponents() + app.applyComponentLayout(true) }, msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { @@ -1233,7 +1237,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.input.SetValue("line1\n") app.state.InputText = app.input.Value() - app.resizeComponents() + app.applyComponentLayout(true) }, msg: tea.KeyMsg{Type: tea.KeyBackspace}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { @@ -1508,7 +1512,7 @@ func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { if len(app.modelPicker.Items()) == 0 { t.Fatalf("expected model picker catalog") } - selected := app.modelPicker.Items()[0].(modelItem).id + selected := app.modelPicker.Items()[0].(selectionItem).id app.modelPicker.Select(0) model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -1553,7 +1557,7 @@ func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { selectedIndex := -1 selected := "" for idx, item := range app.providerPicker.Items() { - candidate, ok := item.(providerItem) + candidate, ok := item.(selectionItem) if !ok { continue } @@ -1843,7 +1847,7 @@ func TestImmediateSlashCommandsAndLayoutBranches(t *testing.T) { app.transcript.Height = 4 app.transcript.SetContent(strings.Repeat("line\n", 20)) app.transcript.GotoBottom() - app.resizeComposerLayout() + app.applyComponentLayout(false) if app.transcript.Width <= 0 || app.transcript.Height <= 0 { t.Fatalf("expected resizeComposerLayout to keep transcript dimensions positive") } @@ -1885,7 +1889,7 @@ func TestAdditionalRenderingAndToolChunkBranches(t *testing.T) { t.Fatalf("expected width<=0 to return original text, got %q", got) } - rendered := app.renderMessageContent("```\n```", 20, app.styles.messageBody) + rendered, _ := app.renderMessageContentWithCopy("```\n```", 20, app.styles.messageBody, 1) if !strings.Contains(stripANSI(rendered), emptyMessageText) { t.Fatalf("expected empty code block placeholder, got %q", rendered) } @@ -1934,7 +1938,7 @@ func TestTranscriptMouseWheelScrollsOnlyInsideTranscript(t *testing.T) { app.width = 128 app.height = 40 - app.resizeComponents() + app.applyComponentLayout(true) app.transcript.SetContent(strings.Repeat("line\n", 160)) app.transcript.GotoTop() @@ -1987,7 +1991,7 @@ func TestTranscriptMouseWheelScrollsOnlyInsideTranscript(t *testing.T) { app.width = 100 app.height = 32 - app.resizeComponents() + app.applyComponentLayout(true) stackX, stackY, _, stackH := app.transcriptBounds() if stackH <= 0 || !app.isMouseWithinTranscript(tea.MouseMsg{X: stackX + 1, Y: stackY + 1}) { t.Fatalf("expected stacked layout transcript bounds to accept mouse hits") @@ -2010,7 +2014,7 @@ func TestInputMouseWheelScrollsComposer(t *testing.T) { "line11", "line12", }, "\n")) app.state.InputText = app.input.Value() - app.resizeComponents() + app.applyComponentLayout(true) app.focus = panelTranscript app.applyFocus() @@ -2095,6 +2099,8 @@ func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { {Time: fixed, Kind: "provider", Title: "third", Detail: "retry"}, {Time: fixed, Kind: "run", Title: "fourth", Detail: "done"}, } + app.applyComponentLayout(true) + app.rebuildActivity() app.focus = panelActivity if app.focusLabel() != focusLabelActivity { t.Fatalf("expected activity focus label, got %q", app.focusLabel()) @@ -2105,10 +2111,10 @@ func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { preview := app.renderActivityPreview(64) if !strings.Contains(preview, "second") || !strings.Contains(preview, "third") || !strings.Contains(preview, "fourth") { - t.Fatalf("expected last activity entries in preview, got %q", preview) + t.Fatalf("expected latest activity rows in preview, got %q", preview) } if strings.Contains(preview, "first") { - t.Fatalf("expected oldest activity entry to be trimmed from preview, got %q", preview) + t.Fatalf("expected oldest activity row to be clipped from current viewport, got %q", preview) } line := app.renderActivityLine(activityEntry{Time: fixed, Kind: "", Title: "single line", Detail: ""}, 80) @@ -2116,7 +2122,7 @@ func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { t.Fatalf("expected fallback kind without detail suffix, got %q", line) } - rendered := app.renderMessageContent("before\n```go\nfmt.Println(1)\n```\nafter", 30, app.styles.messageBody) + rendered, _ := app.renderMessageContentWithCopy("before\n```go\nfmt.Println(1)\n```\nafter", 30, app.styles.messageBody, 1) rendered = stripANSI(rendered) if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(") || !strings.Contains(rendered, "1)") || !strings.Contains(rendered, "after") { t.Fatalf("expected mixed prose and code to render, got %q", rendered) @@ -2148,7 +2154,7 @@ func TestRenderMessageContentUsesMarkdownRenderer(t *testing.T) { stub := &stubMarkdownRenderer{output: "markdown-rendered"} app.markdownRenderer = stub - rendered := app.renderMessageContent("# Title\n\n- item", 40, app.styles.messageBody) + rendered, _ := app.renderMessageContentWithCopy("# Title\n\n- item", 40, app.styles.messageBody, 1) if !strings.Contains(rendered, "markdown-rendered") { t.Fatalf("expected markdown renderer output, got %q", rendered) } @@ -2165,7 +2171,8 @@ func TestRenderMessageBlockUserContentAlignsWithUserTag(t *testing.T) { t.Fatalf("New() error = %v", err) } - rendered := stripANSI(app.renderMessageBlock(provider.Message{Role: roleUser, Content: "nihao"}, 80)) + renderedMessage, _ := app.renderMessageBlockWithCopy(provider.Message{Role: roleUser, Content: "nihao"}, 80, 1) + rendered := stripANSI(renderedMessage) lines := strings.Split(rendered, "\n") tagLine := "" @@ -2204,7 +2211,8 @@ func TestRenderMessageContentNormalizesRightEdge(t *testing.T) { output: "very long line\nshort\nmid", } - rendered := stripANSI(app.renderMessageContent("ignored", 40, app.styles.messageBody)) + renderedRaw, _ := app.renderMessageContentWithCopy("ignored", 40, app.styles.messageBody, 1) + rendered := stripANSI(renderedRaw) lines := strings.Split(rendered, "\n") if len(lines) < 3 { t.Fatalf("expected multiline output, got %q", rendered) @@ -2229,7 +2237,7 @@ func TestRenderMessageContentShowsPlaceholderWhenMarkdownFails(t *testing.T) { app.markdownRenderer = &stubMarkdownRenderer{err: errors.New("render failed")} content := "before\n```go\nfmt.Println(1)\n```\nafter" - rendered := app.renderMessageContent(content, 50, app.styles.messageBody) + rendered, _ := app.renderMessageContentWithCopy(content, 50, app.styles.messageBody, 1) if !strings.Contains(rendered, "fmt.Println(1)") { t.Fatalf("expected code block to keep rendering when markdown prose fails, got %q", rendered) } @@ -2248,7 +2256,7 @@ func TestRenderMessageContentShowsPlaceholderWhenRendererMissing(t *testing.T) { app.markdownRenderer = nil - rendered := app.renderMessageContent("content", 50, app.styles.messageBody) + rendered, _ := app.renderMessageContentWithCopy("content", 50, app.styles.messageBody, 1) if !strings.Contains(rendered, emptyMessageText) { t.Fatalf("expected placeholder when markdown renderer is missing, got %q", rendered) } @@ -2302,6 +2310,7 @@ func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { app.fileCandidates = []string{"README.md", "internal/tui/update.go", "internal/tui/view.go"} app.input.SetValue("inspect @internal/tui/upd") app.state.InputText = app.input.Value() + app.refreshCommandMenu() menu := app.renderCommandMenu(80) if !strings.Contains(menu, fileMenuTitle) || !strings.Contains(menu, "@internal/tui/update.go") { t.Fatalf("expected file suggestion menu, got %q", menu) @@ -2324,6 +2333,7 @@ func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { app.input.SetValue("& go test ./...") app.state.InputText = app.input.Value() + app.refreshCommandMenu() menu = app.renderCommandMenu(80) if !strings.Contains(menu, shellMenuTitle) || !strings.Contains(menu, workspaceCommandUsage) { t.Fatalf("expected shell hint menu, got %q", menu) diff --git a/internal/tui/view.go b/internal/tui/view.go index 5b14caf9..f248f0a7 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -46,7 +46,14 @@ func (a App) View() string { func (a App) renderHeader(width int) string { status := compactStatusText(a.state.StatusText, max(18, width/3)) if a.state.IsAgentRunning { - status = a.spinner.View() + " " + fallback(status, statusRunning) + if a.runProgressKnown { + progressBar := a.progress + progressBar.Width = clamp(width/7, 12, 26) + progressLabel := fallback(strings.TrimSpace(a.runProgressLabel), fallback(status, statusRunning)) + status = progressBar.ViewAs(a.runProgressValue) + " " + progressLabel + } else { + status = a.spinner.View() + " " + fallback(status, statusRunning) + } } brand := lipgloss.JoinHorizontal( @@ -152,6 +159,11 @@ func (a App) renderPicker(width int, height int) string { subtitle = providerPickerSubtitle body = a.providerPicker.View() } + if a.state.ActivePicker == pickerFile { + title = filePickerTitle + subtitle = filePickerSubtitle + body = a.fileBrowser.View() + } content := lipgloss.JoinVertical( lipgloss.Left, a.styles.panelTitle.Render(title), @@ -217,11 +229,6 @@ func (a App) renderPanel(title string, subtitle string, body string, width int, return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } -func (a App) renderMessageBlock(message provider.Message, width int) string { - rendered, _ := a.renderMessageBlockWithCopy(message, width, 1) - return rendered -} - func (a App) renderMessageBlockWithCopy(message provider.Message, width int, startCopyID int) (string, []copyCodeButtonBinding) { switch message.Role { case roleEvent: @@ -282,60 +289,24 @@ func (a App) renderMessageBlockWithCopy(message provider.Message, width int, sta } func (a App) renderCommandMenu(width int) string { - 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 { + if a.state.ActivePicker != pickerNone || len(a.commandMenu.Items()) == 0 { return "" } - - lines := make([]string, 0, len(suggestions)+1) - lines = append(lines, a.styles.commandMenuTitle.Render(commandMenuTitle)) - for _, suggestion := range suggestions { - usageStyle := a.styles.commandUsage - if suggestion.Match { - usageStyle = a.styles.commandUsageMatch - } - lines = append(lines, lipgloss.JoinHorizontal( - lipgloss.Top, - usageStyle.Render(suggestion.Command.Usage), - lipgloss.NewStyle().Width(2).Render(""), - a.styles.commandDesc.Render(suggestion.Command.Description), - )) + title := commandMenuTitle + if strings.TrimSpace(a.commandMenuMeta.Title) != "" { + title = a.commandMenuMeta.Title } - - return a.styles.commandMenu.Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) + body := strings.TrimSpace(a.commandMenu.View()) + if body == "" { + return "" + } + return a.styles.commandMenu.Width(width).Render( + lipgloss.JoinVertical( + lipgloss.Left, + a.styles.commandMenuTitle.Render(title), + body, + ), + ) } func (a App) commandMenuHeight(width int) int { @@ -353,11 +324,6 @@ func (a App) renderHelp(width int) string { return a.styles.footer.Width(width).Render(helpContent) } -func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss.Style) string { - rendered, _ := a.renderMessageContentWithCopy(content, width, bodyStyle, 1) - return rendered -} - func (a App) renderMessageContentWithCopy(content string, width int, bodyStyle lipgloss.Style, startCopyID int) (string, []copyCodeButtonBinding) { if a.markdownRenderer == nil { return bodyStyle.Render(emptyMessageText), nil @@ -502,22 +468,12 @@ func (a App) renderActivityPreview(width int) string { if len(a.activities) == 0 { return "" } - - entries := a.activities - if len(entries) > activityPreviewEntries { - entries = entries[len(entries)-activityPreviewEntries:] - } - - lines := make([]string, 0, len(entries)) - bodyWidth := max(10, width-4) - for _, entry := range entries { - lines = append(lines, a.renderActivityLine(entry, bodyWidth)) - } + content := a.activity.View() return a.renderPanel( activityTitle, activitySubtitle, - strings.Join(lines, "\n"), + content, width, a.activityPreviewHeight(), a.focus == panelActivity, From 61fef7b5c20ab1158b35e19ceb94c8e26cf6dd7b Mon Sep 17 00:00:00 2001 From: creatang Date: Sat, 4 Apr 2026 18:03:36 +0800 Subject: [PATCH 2/2] test(tui): raise patch coverage for componentized paths --- internal/tui/command_menu_test.go | 163 ++++++++++++++++++++++++++++ internal/tui/input_features_test.go | 55 ++++++++++ internal/tui/update_test.go | 89 +++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 internal/tui/command_menu_test.go diff --git a/internal/tui/command_menu_test.go b/internal/tui/command_menu_test.go new file mode 100644 index 00000000..dfdfcfe3 --- /dev/null +++ b/internal/tui/command_menu_test.go @@ -0,0 +1,163 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +func TestCommandMenuItemAndDelegateHelpers(t *testing.T) { + item := commandMenuItem{ + title: "/status", + description: "show status", + filter: " custom FILTER ", + } + if item.Title() != "/status" || item.Description() != "show status" { + t.Fatalf("unexpected title/description: %+v", item) + } + if got := item.FilterValue(); got != "custom filter" { + t.Fatalf("expected trimmed lowercase filter, got %q", got) + } + + item.filter = "" + if got := item.FilterValue(); got != "/status show status" { + t.Fatalf("expected fallback filter text, got %q", got) + } + + delegate := commandMenuDelegate{styles: newStyles()} + if delegate.Height() != 1 || delegate.Spacing() != 0 { + t.Fatalf("unexpected delegate size: height=%d spacing=%d", delegate.Height(), delegate.Spacing()) + } + if cmd := delegate.Update(nil, nil); cmd != nil { + t.Fatalf("expected nil update cmd, got %v", cmd) + } +} + +func TestCommandMenuBehaviorPaths(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.state.CurrentWorkdir = t.TempDir() + app.fileCandidates = []string{"internal/tui/update.go", "internal/tui/view.go"} + app.input.SetValue("inspect @") + app.state.InputText = app.input.Value() + app.refreshCommandMenu() + if !app.commandMenuHasSuggestions() || app.commandMenuMeta.Title != fileMenuTitle { + t.Fatalf("expected file menu suggestions, meta=%+v items=%d", app.commandMenuMeta, len(app.commandMenu.Items())) + } + if !app.applySelectedCommandSuggestion() { + t.Fatalf("expected browse suggestion to open file browser") + } + if app.state.ActivePicker != pickerFile { + t.Fatalf("expected pickerFile after browse suggestion, got %v", app.state.ActivePicker) + } + + app.closePicker() + app.input.SetValue("/") + app.state.InputText = app.input.Value() + app.refreshCommandMenu() + if len(app.commandMenu.Items()) == 0 { + t.Fatalf("expected slash command menu items") + } + if len(app.commandMenu.Items()) > 1 { + selectedTitle := app.commandMenu.Items()[1].(commandMenuItem).title + app.commandMenu.Select(1) + app.refreshCommandMenu() + currentTitle := app.commandMenu.SelectedItem().(commandMenuItem).title + if currentTitle != selectedTitle { + t.Fatalf("expected selection retained, got %q want %q", currentTitle, selectedTitle) + } + } + + if _, handled := app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyDown}); !handled { + t.Fatalf("expected key down to be handled") + } + if _, handled := app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}); handled { + t.Fatalf("expected rune key not to be handled by command menu") + } + + app.state.ActivePicker = pickerModel + app.refreshCommandMenu() + if app.commandMenuHasSuggestions() || strings.TrimSpace(app.commandMenuMeta.Title) != "" { + t.Fatalf("expected menu cleared while picker active") + } + app.state.ActivePicker = pickerNone + + app.commandMenu.SetItems([]list.Item{selectionItem{id: "x", name: "not command item"}}) + app.commandMenu.Select(0) + if app.applySelectedCommandSuggestion() { + t.Fatalf("expected false when selected item type is invalid") + } + + app.input.SetValue("abc") + app.state.InputText = app.input.Value() + app.commandMenu.SetItems([]list.Item{commandMenuItem{ + replacement: "ignored", + useReplaceRange: true, + replaceStart: -1, + replaceEnd: 1, + }}) + app.commandMenu.Select(0) + if app.applySelectedCommandSuggestion() { + t.Fatalf("expected false for invalid replace range") + } + + app.commandMenu.SetItems([]list.Item{commandMenuItem{replacement: "/status"}}) + app.commandMenu.Select(0) + if !app.applySelectedCommandSuggestion() || app.state.InputText != "/status" { + t.Fatalf("expected direct replacement, got %q", app.state.InputText) + } + app.commandMenu.SetItems([]list.Item{commandMenuItem{replacement: "/status"}}) + app.commandMenu.Select(0) + if app.applySelectedCommandSuggestion() { + t.Fatalf("expected no-op replacement to return false") + } +} + +func TestBuildCommandMenuItemsVariants(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + app.state.CurrentWorkdir = t.TempDir() + + items, meta := app.buildCommandMenuItems("&", 80) + if len(items) != 1 || meta.Title != shellMenuTitle || !items[0].useReplaceRange { + t.Fatalf("expected bare workspace command helper, got meta=%+v items=%+v", meta, items) + } + + items, meta = app.buildCommandMenuItems("& git status", 80) + if len(items) != 1 || meta.Title != shellMenuTitle || items[0].useReplaceRange { + t.Fatalf("expected concrete workspace command helper, got meta=%+v items=%+v", meta, items) + } + + items, meta = app.buildCommandMenuItems("not-a-command", 80) + if len(items) != 0 || strings.TrimSpace(meta.Title) != "" { + t.Fatalf("expected no suggestions for plain input, got meta=%+v items=%+v", meta, items) + } + + app.fileCandidates = []string{"internal/tui/update.go"} + fileItems := app.fileMenuSuggestions("inspect @") + if len(fileItems) == 0 || !fileItems[0].openFileBrowser { + t.Fatalf("expected browse item for empty file query, got %+v", fileItems) + } + + app.fileCandidates = nil + if suggestions := app.fileMenuSuggestions("inspect @missing"); len(suggestions) != 0 { + t.Fatalf("expected empty suggestions when query misses all candidates, got %+v", suggestions) + } + + app.state.CurrentWorkdir = "" + app.openFileBrowser() + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected openFileBrowser to no-op when workdir is empty") + } +} diff --git a/internal/tui/input_features_test.go b/internal/tui/input_features_test.go index 24ca63ec..7e587771 100644 --- a/internal/tui/input_features_test.go +++ b/internal/tui/input_features_test.go @@ -194,4 +194,59 @@ func TestWorkspaceFileHelpers(t *testing.T) { t.Fatalf("expected applySelectedCommandSuggestion to fail without @ token") } }) + + t.Run("apply file reference handles replace append and path resolution", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + workdir := t.TempDir() + inside := filepath.Join(workdir, "internal", "tui", "update.go") + outside := filepath.Join(t.TempDir(), "outside.go") + app.state.CurrentWorkdir = workdir + + app.input.SetValue("inspect @old/path.go") + app.state.InputText = app.input.Value() + if err := app.applyFileReference(inside); err != nil { + t.Fatalf("applyFileReference(replace) error = %v", err) + } + if !strings.Contains(app.state.InputText, "@internal/tui/update.go") { + t.Fatalf("expected replaced relative reference, got %q", app.state.InputText) + } + + app.input.SetValue("inspect") + app.state.InputText = app.input.Value() + if err := app.applyFileReference(inside); err != nil { + t.Fatalf("applyFileReference(append with space) error = %v", err) + } + if !strings.HasPrefix(app.state.InputText, "inspect @internal/tui/update.go") { + t.Fatalf("expected appended reference with separator, got %q", app.state.InputText) + } + + app.input.SetValue("inspect ") + app.state.InputText = app.input.Value() + if err := app.applyFileReference(inside); err != nil { + t.Fatalf("applyFileReference(append without extra space) error = %v", err) + } + if strings.Contains(app.state.InputText, "inspect @") { + t.Fatalf("expected single separator before reference, got %q", app.state.InputText) + } + + app.input.SetValue(" ") + app.state.InputText = app.input.Value() + if err := app.applyFileReference(outside); err != nil { + t.Fatalf("applyFileReference(empty input) error = %v", err) + } + outsideAbs, _ := filepath.Abs(outside) + if !strings.Contains(app.state.InputText, "@"+filepath.ToSlash(outsideAbs)) { + t.Fatalf("expected absolute reference for outside file, got %q", app.state.InputText) + } + + if err := app.applyFileReference(" "); err == nil { + t.Fatalf("expected empty path to fail") + } + }) } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index b67c6f4c..1f5056ad 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -2486,6 +2486,95 @@ func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { } } +func TestActivityMouseFilePickerAndProgressRendering(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.width = 120 + app.height = 38 + app.applyComponentLayout(true) + x0, y0, _, _ := app.activityBounds() + if app.isMouseWithinActivity(tea.MouseMsg{X: x0, Y: y0}) { + t.Fatalf("expected empty activity panel hit test to be false") + } + if app.handleActivityMouse(tea.MouseMsg{X: x0, Y: y0, Type: tea.MouseWheelDown, Button: tea.MouseButtonWheelDown}) { + t.Fatalf("expected mouse handling to be false without activities") + } + + app.appendActivity("tool", "Running tool", "detail", false) + app.applyComponentLayout(true) + ax, ay, aw, ah := app.activityBounds() + if aw <= 0 || ah <= 0 { + t.Fatalf("expected visible activity bounds, got width=%d height=%d", aw, ah) + } + inside := tea.MouseMsg{ + X: ax + 1, + Y: ay + 1, + Type: tea.MouseWheelDown, + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + } + app.focus = panelTranscript + if !app.handleActivityMouse(inside) { + t.Fatalf("expected activity wheel event to be handled") + } + if app.focus != panelActivity { + t.Fatalf("expected focus to move to activity panel, got %v", app.focus) + } + + app.state.ActivePicker = pickerModel + if app.handleActivityMouse(inside) { + t.Fatalf("expected activity mouse ignored when picker is active") + } + app.state.ActivePicker = pickerNone + if app.isMouseWithinActivity(tea.MouseMsg{X: ax + aw + 2, Y: ay}) { + t.Fatalf("expected out-of-bound mouse point to be rejected") + } + + app.state.ActivePicker = pickerFile + if pickerView := stripANSI(app.renderPicker(48, 12)); !strings.Contains(pickerView, filePickerTitle) { + t.Fatalf("expected file picker title, got %q", pickerView) + } + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyDown}) + app = model.(App) + if cmd != nil { + _ = collectTeaMessages(cmd) + } + + app.state.IsAgentRunning = true + app.state.StatusText = "thinking" + app.runProgressKnown = true + app.runProgressValue = 0.45 + app.runProgressLabel = "Planning" + header := stripANSI(app.renderHeader(100)) + if !strings.Contains(header, "Planning") { + t.Fatalf("expected progress label in header, got %q", header) + } + app.runProgressLabel = "" + app.state.StatusText = "" + header = stripANSI(app.renderHeader(100)) + if !strings.Contains(header, statusRunning) { + t.Fatalf("expected running fallback text in header, got %q", header) + } + + app.state.ActivePicker = pickerFile + snapshot := app.currentStatusSnapshot() + if snapshot.PickerLabel != "file" { + t.Fatalf("expected picker label file, got %q", snapshot.PickerLabel) + } + + app.runProgressKnown = true + modelAny, _ := app.Update(RuntimeClosedMsg{}) + closed := modelAny.(App) + if closed.runProgressKnown { + t.Fatalf("expected RuntimeClosedMsg to clear run progress") + } +} + func newTestConfigManager(t *testing.T) *config.Manager { t.Helper() manager := config.NewManager(config.NewLoader(t.TempDir(), config.DefaultConfig()))