From c8d8329dbb48d4241e9bed69eb2945ef9864d27f Mon Sep 17 00:00:00 2001 From: loveuer Date: Thu, 25 Jun 2026 20:40:50 -0700 Subject: [PATCH] feat(tui): codex-style input, history, bang commands, single-line status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Arrow-key input history (up/down with multiline awareness) - Bang shell commands via ! prefix (requires --yolo) - Codex-style input bar with mode indicator (R/Y/⌘) - Command palette virtual scrolling - Multiline input viewport fix - Merged model pills + footer into single status line - Removed shift+enter (wait for TUI library support) - Removed default footer hints --- README.md | 5 +- internal/session/manager.go | 8 +- internal/session/manager_test.go | 12 + internal/tui/app.go | 516 +++++++++++++++++++++++++------ internal/tui/app_test.go | 262 +++++++++++++--- internal/tui/theme.go | 100 +++--- 6 files changed, 729 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index c4cafbf..0b2e862 100644 --- a/README.md +++ b/README.md @@ -100,11 +100,13 @@ go run . [flags] ## TUI Controls - `enter` sends the message. -- `ctrl+j` or `alt+enter` inserts a newline. +- `ctrl+j` inserts a newline. +- `up` recalls the previous input; `ctrl+p` / `ctrl+n` navigate input history. - The input starts at one line and grows up to six lines. - `page up` / `page down` scrolls the transcript. - `esc` cancels a running response. - Type `/` to show available commands. +- In `--yolo` mode, type `! ` to run a shell command from the configured working directory. Slash commands: @@ -116,6 +118,7 @@ Slash commands: - `/model` shows current provider, model, thinking, and switch usage. - `/model provider ` switches provider for the current session. - `/model model ` or `/model ` switches model for the current session. + In the TUI, type `/model ` or `/model model ` to choose from configured model IDs in the candidate palette. - `/model thinking ` changes thinking for the current session. - `/sessions` lists saved sessions. - `/resume [id]` resumes by ID or opens an interactive picker. diff --git a/internal/session/manager.go b/internal/session/manager.go index 873acc1..1c7ce6f 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -288,9 +288,13 @@ func (m *Manager) providerNames() []string { return names } +func (m *Manager) AvailableModels() []string { + return append([]string(nil), m.provider.Models...) +} + func contains(values []string, value string) bool { - for _, item := range values { - if item == value { + for _, v := range values { + if v == value { return true } } diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index 75fdd3e..71cdd7b 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -109,6 +109,18 @@ func TestManagerRejectsUnknownModelAndThinking(t *testing.T) { } } +func TestManagerExposesModelCommandCandidates(t *testing.T) { + manager, _, _ := testManager(t) + models := manager.AvailableModels() + if len(models) != 2 || models[0] != "model-a" || models[1] != "model-b" { + t.Fatalf("AvailableModels() = %v", models) + } + models[0] = "mutated" + if got := manager.AvailableModels()[0]; got != "model-a" { + t.Fatalf("mutation leaked: %q", got) + } +} + func TestManagerSaveAndResume(t *testing.T) { manager, assistant, _ := testManager(t) diff --git a/internal/tui/app.go b/internal/tui/app.go index db7cc10..7bee8a2 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -50,6 +50,7 @@ type ModelManager interface { Rename(name string) (string, error) Sessions() (string, error) NewSession() (string, error) + AvailableModels() []string } func Run(ctx context.Context, assistant *agent.Agent, opts Options) error { @@ -90,12 +91,13 @@ type model struct { width int height int - messages []message - status string - contextUsage string - inputHistory []string - historyIndex int - draftInput string + messages []message + status string + contextUsage string + inputHistory []string + historyIndex int + draftInput string + commandCursor int running bool cancel context.CancelFunc @@ -133,6 +135,13 @@ type commandMatch struct { score int } +type commandSuggestion struct { + label string + description string + value string + executable bool +} + var slashCommands = []slashCommand{ {Name: "/clear", Description: "reset context"}, {Name: "/compact", Description: "compress context"}, @@ -157,7 +166,7 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model { st := th.styles() input := textarea.New() - input.Placeholder = "Message agentu..." + input.Placeholder = "" input.Prompt = "" input.ShowLineNumbers = false input.MaxHeight = maxInputLines @@ -197,8 +206,6 @@ func (m model) Init() tea.Cmd { m.syncMessages() return textarea.Blink } - -// syncMessages rebuilds the TUI display messages from the agent's conversation history. func (m *model) syncMessages() { if m.agent == nil { return @@ -246,6 +253,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.picking { return m.updatePicker(msg) } + if handled, next, cmd := m.updateCommandSuggestions(msg); handled { + return next, cmd + } switch msg.String() { case "ctrl+c": if m.cancel != nil { @@ -263,6 +273,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } return m.submit() + case "up": + if m.running { + return m, nil + } + if m.input.Line() == 0 { + m.previousInputHistory() + m.afterInputChanged() + return m, nil + } + case "down": + if m.running { + return m, nil + } + value := m.input.Value() + lineCount := 1 + if value != "" { + lineCount = len(strings.Split(value, "\n")) + } + if m.input.Line() >= lineCount-1 { + m.nextInputHistory() + m.afterInputChanged() + return m, nil + } case "ctrl+p": if m.running { return m, nil @@ -277,7 +310,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.nextInputHistory() m.afterInputChanged() return m, nil - case "alt+enter", "ctrl+j": + case "ctrl+j": return m.insertInputNewline() } @@ -338,7 +371,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.running { nextInput, cmd := m.input.Update(msg) m.input = nextInput - m.afterInputChanged() cmds = append(cmds, cmd) } @@ -356,7 +388,7 @@ func (m model) View() string { if m.picking { parts := []string{m.viewport.View()} - parts = append(parts, m.pickerView(), m.modelMetaView(), m.footerView()) + parts = append(parts, m.pickerView(), m.footerView()) body := lipgloss.JoinVertical(lipgloss.Left, parts...) return m.styles.App.Width(m.width).Height(m.height).Render(body) } @@ -365,7 +397,7 @@ func (m model) View() string { if activity := m.activityView(); activity != "" { parts = append(parts, activity) } - parts = append(parts, m.inputView(), m.modelMetaView(), m.footerView()) + parts = append(parts, m.inputView(), m.footerView()) body := lipgloss.JoinVertical(lipgloss.Left, parts...) return m.styles.App.Width(m.width).Height(m.height).Render(body) } @@ -393,6 +425,10 @@ func (m *model) submit() (tea.Model, tea.Cmd) { return m.runLocalCommand(input) } + if strings.HasPrefix(input, "!") { + return m.runLocalCommand(input) + } + if strings.HasPrefix(input, "/") { return m.runLocalCommand(input) } @@ -438,6 +474,16 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) { m.historyIndex = -1 m.draftInput = "" m.afterInputChanged() + // The textarea's repositionView was called inside Update with the old + // viewport height, so the YOffset may be stale after syncInputHeight + // changed the height. Send up then down to force repositionView to + // recalculate with the new height. + up := tea.KeyMsg{Type: tea.KeyUp} + vp, _ := m.input.Update(up) + m.input = vp + down := tea.KeyMsg{Type: tea.KeyDown} + vp, _ = m.input.Update(down) + m.input = vp return *m, cmd } @@ -467,7 +513,7 @@ func (m *model) appendAssistantChunk(chunk string) { func (m *model) resize() { width := max(40, m.width) - inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize()) + inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1) m.input.SetWidth(inputWidth) m.syncInputHeight() @@ -668,72 +714,25 @@ type metaPill struct { accent bool } -func (m model) renderMetaPills(parts []metaPill) string { - if len(parts) == 0 { - return "" +func (m model) inputModeView() string { + if strings.HasPrefix(strings.TrimSpace(m.input.Value()), "!") { + return m.styles.InputModeCommand.Render(iconCommand) } - - plain := make([]string, len(parts)) - for i, part := range parts { - plain[i] = part.text + if m.yolo { + return m.styles.InputModeYolo.Render("Y") } - - width := max(1, m.width-2) - visible := len(plain) - for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width { - visible-- - } - if lipgloss.Width(strings.Join(plain[:visible], " ")) > width { - return m.styles.PillAccent.Render(fitLine(plain[0], width)) - } - - rendered := make([]string, 0, visible) - for i := 0; i < visible; i++ { - style := m.styles.Pill - if parts[i].accent { - style = m.styles.PillAccent - } - rendered = append(rendered, style.Render(parts[i].text)) - } - return strings.Join(rendered, " ") + return m.styles.InputModeReadonly.Render("R") } -func (m model) modelMetaView() string { - mode := "read-only tools" - if m.yolo { - mode = "yolo tools" - } - - modelName := m.modelName - if m.models != nil { - if currentModel := m.models.CurrentModel(); currentModel != "" { - modelName = currentModel - } - } - - parts := []metaPill{{text: iconModel + " " + modelName, accent: true}} - if m.models != nil { - if provider := m.models.CurrentProvider(); provider != "" { - parts = append(parts, metaPill{text: "provider " + provider}) - } - if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" { - parts = append(parts, metaPill{text: "thinking " + currentThinking}) - } - if sessionName := m.models.CurrentSessionName(); sessionName != "" { - parts = append(parts, metaPill{text: iconSession + " " + sessionName}) - } - } - if m.contextUsage != "" { - parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage}) - } - parts = append(parts, metaPill{text: mode}, metaPill{text: "theme " + string(m.theme.Mode)}) - - return m.styles.ModelMeta.Width(max(1, m.width)).Render(m.renderMetaPills(parts)) +func (m model) inputModeWidth() int { + return lipgloss.Width(m.inputModeView()) } func (m model) inputView() string { palette := m.commandPaletteView() - box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View()) + gap := lipgloss.NewStyle().Background(m.styles.InputBox.GetBackground()).Render(" ") + content := lipgloss.JoinHorizontal(lipgloss.Top, m.inputModeView(), gap, m.input.View()) + box := m.styles.InputBox.Width(max(20, m.width)).Render(content) if palette != "" { return lipgloss.JoinVertical(lipgloss.Left, palette, box) } @@ -751,13 +750,85 @@ func (m model) activityView() string { line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels" return m.styles.Activity.Render(fitLine(line, max(1, m.width-2))) } - func (m model) footerView() string { - hint := "enter send · ctrl+j newline · alt+enter newline · ctrl+p/ctrl+n history · / commands" + hint := "" if m.running { hint = "esc cancel · ctrl+c quit" } - return m.styles.Footer.Width(max(1, m.width)).Render(fitLine(hint, max(1, m.width-2))) + if m.showCommandPalette() { + if sug, ok := m.selectedCommandSuggestion(); ok && sug.executable { + hint = "↑/↓ select · enter apply · tab fill" + } + } + + inner := max(1, m.width-2) // Footer padding(0,1) + pills := m.modelMetaPillsClamped(inner / 3) + pillsW := lipgloss.Width(pills) + + var line string + if pills != "" { + remain := max(1, inner-pillsW-1) + line = pills + " " + fitLine(hint, remain) + } else { + line = fitLine(hint, inner) + } + return m.styles.Footer.Width(max(1, m.width)).Render(line) +} + +func (m model) modelMetaPillsClamped(maxW int) string { + modelName := m.modelName + if m.models != nil { + if currentModel := m.models.CurrentModel(); currentModel != "" { + modelName = currentModel + } + } + + parts := []metaPill{{text: iconModel + " " + modelName, accent: true}} + if m.models != nil { + if provider := m.models.CurrentProvider(); provider != "" { + parts = append(parts, metaPill{text: provider}) + } + if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" { + parts = append(parts, metaPill{text: currentThinking}) + } + if sessionName := m.models.CurrentSessionName(); sessionName != "" { + parts = append(parts, metaPill{text: iconSession + " " + sessionName}) + } + } + if m.contextUsage != "" { + parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage}) + } + + return m.renderMetaPillsClamped(parts, maxW) +} + +func (m model) renderMetaPillsClamped(parts []metaPill, width int) string { + if len(parts) == 0 { + return "" + } + + plain := make([]string, len(parts)) + for i, part := range parts { + plain[i] = part.text + } + + visible := len(plain) + for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width { + visible-- + } + if visible > 0 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width { + return "" + } + + rendered := make([]string, 0, visible) + for i := 0; i < visible; i++ { + style := m.styles.Pill + if parts[i].accent { + style = m.styles.PillAccent + } + rendered = append(rendered, style.Render(parts[i].text)) + } + return strings.Join(rendered, " ") } type eventWriter struct { @@ -801,6 +872,7 @@ func isViewportKey(key string) bool { } func (m *model) afterInputChanged() { + m.commandCursor = 0 m.syncInputHeight() if m.width > 0 && m.height > 0 { m.resize() @@ -882,18 +954,37 @@ func (m model) inputBlockHeight() int { } func (m model) commandPaletteHeight() int { - matches := commandMatches(commandPaletteQuery(m.input.Value())) - count := len(matches) - if count > maxCommandPaletteItems { - count = maxCommandPaletteItems + sugs := m.commandSuggestions() + total := len(sugs) + if !m.showCommandPalette() { + return 0 } + count := min(total, maxCommandPaletteItems) if count == 0 { count = 1 } - return 1 + count + m.styles.Palette.GetVerticalFrameSize() + scrollUp := 0 + scrollDown := 0 + if total > maxCommandPaletteItems { + visible := min(total, maxCommandPaletteItems) + scroll := max(0, m.commandCursor-visible+1) + if m.commandCursor < scroll { + scroll = m.commandCursor + } + if scroll > 0 { + scrollUp = 1 + } + if scroll+visible < total { + scrollDown = 1 + } + } + return 1 + scrollUp + count + scrollDown + m.styles.Palette.GetVerticalFrameSize() } func (m model) showCommandPalette() bool { + if len(m.commandSuggestions()) > 0 { + return true + } value := strings.TrimSpace(m.input.Value()) return strings.HasPrefix(value, "/") && !strings.Contains(value, " ") } @@ -902,18 +993,42 @@ func (m model) commandPaletteView() string { if !m.showCommandPalette() { return "" } - matches := commandMatches(commandPaletteQuery(m.input.Value())) - lines := []string{m.styles.Header.Render(iconCommand + " Commands")} - if len(matches) == 0 { - lines = append(lines, m.styles.Muted.Render("No matching commands")) - } else { - for i, match := range matches { - if i >= maxCommandPaletteItems { - break - } - lines = append(lines, fmt.Sprintf("%s %s", m.styles.PaletteMatch.Render(match.command.Name), m.styles.Muted.Render(match.command.Description))) + sugs := m.commandSuggestions() + if len(sugs) > 0 { + header := iconCommand + " Commands" + if sugs[0].executable { + header = iconCommand + " Model candidates" } + lines := []string{m.styles.Header.Render(header)} + m.clampCommandCursor() + total := len(sugs) + visible := min(total, maxCommandPaletteItems) + scroll := max(0, m.commandCursor-visible+1) + if m.commandCursor < scroll { + scroll = m.commandCursor + } + if scroll > 0 { + lines = append(lines, m.styles.Muted.Render(" ↑ more")) + } + for i := scroll; i < scroll+visible && i < total; i++ { + s := sugs[i] + prefix := " " + if i == m.commandCursor { + prefix = "▸ " + } + lines = append(lines, fmt.Sprintf("%s%s %s", prefix, m.styles.PaletteMatch.Render(s.label), m.styles.Muted.Render(s.description))) + } + if scroll+visible < total { + lines = append(lines, m.styles.Muted.Render(" ↓ more")) + } + return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n")) } + value := strings.TrimSpace(m.input.Value()) + if strings.Contains(value, " ") { + return "" + } + lines := []string{m.styles.Header.Render(iconCommand + " Commands")} + lines = append(lines, m.styles.Muted.Render("No matching commands")) return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n")) } @@ -991,6 +1106,162 @@ func subsequenceScore(candidate string, query string, base int) (int, bool) { return base + gaps, true } +func (m model) commandSuggestions() []commandSuggestion { + input := m.input.Value() + if sugs := m.modelCommandSuggestions(input); len(sugs) > 0 { + return sugs + } + value := strings.TrimSpace(input) + if strings.HasPrefix(value, "/") && !strings.Contains(value, " ") { + return slashCommandSuggestions(commandPaletteQuery(input)) + } + return nil +} + +func slashCommandSuggestions(query string) []commandSuggestion { + matches := commandMatches(query) + sugs := make([]commandSuggestion, 0, len(matches)) + for _, m := range matches { + sugs = append(sugs, commandSuggestion{ + label: m.command.Name, + description: m.command.Description, + value: m.command.Name, + }) + } + return sugs +} + +func splitModelCommandTail(input string) (query string, ok bool) { + fields := strings.Fields(input) + if len(fields) == 0 || fields[0] != "/model" { + return "", false + } + hasTrailingSpace := strings.TrimRight(input, " \t") != input + switch len(fields) { + case 1: + if !hasTrailingSpace { + return "", false + } + return "", true + case 2: + if fields[1] == "provider" || fields[1] == "thinking" { + return "", false + } + if fields[1] == "model" { + if !hasTrailingSpace { + return "", false + } + return "", true + } + if hasTrailingSpace { + return fields[1], true + } + return fields[1], true + case 3: + if fields[1] != "model" { + return "", false + } + if hasTrailingSpace { + return fields[2], true + } + return fields[2], true + default: + return "", false + } +} + +func (m model) modelCommandSuggestions(input string) []commandSuggestion { + if m.models == nil { + return nil + } + query, ok := splitModelCommandTail(input) + if !ok { + return nil + } + available := m.models.AvailableModels() + if len(available) == 0 { + return nil + } + current := m.models.CurrentModel() + candidates := make([]commandSuggestion, 0, len(available)) + for _, id := range available { + desc := "switch model" + if id == current { + desc = "current model" + } + candidates = append(candidates, commandSuggestion{ + label: id, + description: desc, + value: "/model model " + id, + executable: true, + }) + } + return scoredCommandSuggestions(candidates, query) +} + +func scoredCommandSuggestions(candidates []commandSuggestion, query string) []commandSuggestion { + if query == "" { + return candidates + } + type scored struct { + sug commandSuggestion + score int + idx int + } + var kept []scored + for i, c := range candidates { + if s, ok := scoreCommandSuggestion(c.label, query); ok { + kept = append(kept, scored{sug: c, score: s, idx: i}) + } + } + sort.Slice(kept, func(i, j int) bool { + if kept[i].score != kept[j].score { + return kept[i].score < kept[j].score + } + return kept[i].idx < kept[j].idx + }) + out := make([]commandSuggestion, 0, len(kept)) + for _, s := range kept { + out = append(out, s.sug) + } + return out +} + +func scoreCommandSuggestion(label string, query string) (int, bool) { + query = strings.ToLower(strings.TrimSpace(query)) + name := strings.ToLower(label) + if query == "" || query == name { + return 0, true + } + if strings.HasPrefix(name, query) { + return 100 + len(name) - len(query), true + } + return subsequenceScore(name, query, 200) +} + +func (m model) selectedCommandSuggestion() (commandSuggestion, bool) { + sugs := m.commandSuggestions() + m.clampCommandCursor() + if m.commandCursor >= 0 && m.commandCursor < len(sugs) { + return sugs[m.commandCursor], true + } + return commandSuggestion{}, false +} + +func (m *model) clampCommandCursor() { + sugs := m.commandSuggestions() + if len(sugs) == 0 { + m.commandCursor = 0 + return + } + if m.commandCursor < 0 { + m.commandCursor = 0 + } + if m.commandCursor >= len(sugs) { + m.commandCursor = len(sugs) - 1 + } +} + func (m model) slashSuggestionCount() int { return m.commandPaletteHeight() } @@ -1007,11 +1278,7 @@ func (m model) modelInfo() string { if m.models != nil { return m.models.Info() } - mode := "read-only tools" - if m.yolo { - mode = "yolo tools" - } - return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode) + return m.modelName } func (m *model) refreshContextUsage() { @@ -1146,7 +1413,31 @@ func (m *model) handleThemeCommand(input string) (tea.Model, tea.Cmd) { return *m, nil } +func (m model) handleBangCommand(input string) (string, error) { + command := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(input), "!")) + if command == "" { + return "", fmt.Errorf("usage: ! ") + } + if !m.yolo { + return "", fmt.Errorf("shell command input requires --yolo") + } + out, err := m.runShellCommand(command) + if err != nil { + if strings.TrimSpace(out) != "" { + return "", fmt.Errorf("%s\n%w", strings.TrimRight(out, "\n"), err) + } + return "", err + } + if strings.TrimSpace(out) == "" { + return fmt.Sprintf("$ %s\n(no output)", command), nil + } + return out, nil +} + func (m model) handleLocalCommand(input string) (string, error) { + if strings.HasPrefix(strings.TrimSpace(input), "!") { + return m.handleBangCommand(input) + } fields := strings.Fields(input) if len(fields) == 0 { return "", fmt.Errorf("empty command") @@ -1342,6 +1633,41 @@ func (m *model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return *m, nil } +func (m *model) updateCommandSuggestions(msg tea.KeyMsg) (bool, tea.Model, tea.Cmd) { + if m.running || len(m.commandSuggestions()) == 0 { + return false, *m, nil + } + m.clampCommandCursor() + switch msg.String() { + case "up": + if m.commandCursor > 0 { + m.commandCursor-- + } + return true, *m, nil + case "down": + if m.commandCursor < len(m.commandSuggestions())-1 { + m.commandCursor++ + } + return true, *m, nil + case "tab": + if sug, ok := m.selectedCommandSuggestion(); ok { + m.input.SetValue(sug.value) + m.afterInputChanged() + } + return true, *m, nil + case "enter": + if sug, ok := m.selectedCommandSuggestion(); ok && sug.executable { + m.rememberInput(sug.value) + m.input.SetValue(sug.value) + m.afterInputChanged() + next, cmd := m.runLocalCommand(sug.value) + return true, next, cmd + } + return false, *m, nil + } + return false, *m, nil +} + func (m model) pickerView() string { if !m.picking || len(m.pickerItems) == 0 { return "" diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 1a68a9f..a570ff4 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -21,7 +21,7 @@ func TestModelRendersChatShell(t *testing.T) { m = next.(model) rendered := m.View() - for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter", "ctrl+p/ctrl+n history"} { + for _, want := range []string{"test-model"} { if !strings.Contains(rendered, want) { t.Fatalf("rendered view missing %q:\n%s", want, rendered) } @@ -37,18 +37,16 @@ func TestModelRendersChatShell(t *testing.T) { } } -func TestInputTextDoesNotPaintBackground(t *testing.T) { +func TestInputTextBackgroundMatchesInputBox(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) - for name, color := range map[string]any{ - "input box": m.styles.InputBox.GetBackground(), + want := lightTheme().InputBg + for name, color := range map[string]lipgloss.TerminalColor{ "focused base": m.input.FocusedStyle.Base.GetBackground(), "focused cursor line": m.input.FocusedStyle.CursorLine.GetBackground(), "blurred base": m.input.BlurredStyle.Base.GetBackground(), "blurred cursor line": m.input.BlurredStyle.CursorLine.GetBackground(), } { - if _, ok := color.(lipgloss.NoColor); !ok { - t.Fatalf("%s should not paint input text background", name) - } + requireStyleColor(t, name, color, want) } } @@ -58,12 +56,12 @@ func TestInputBoxIsRoomier(t *testing.T) { m = next.(model) if got := m.styles.InputBox.GetVerticalFrameSize(); got != 2 { - t.Fatalf("input box vertical frame should stay compact, got %d", got) + t.Fatalf("input box vertical frame should be 2 with vertical padding, got %d", got) } - if got := m.styles.InputBox.GetHorizontalFrameSize(); got < 6 { - t.Fatalf("input box horizontal frame should include roomier padding, got %d", got) + if got := m.styles.InputBox.GetHorizontalFrameSize(); got != 3 { + t.Fatalf("input box horizontal frame should be 3 (asymmetric padding), got %d", got) } - if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize(); got != want { + if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1; got != want { t.Fatalf("input content width = %d, want %d", got, want) } rendered := m.inputView() @@ -78,13 +76,8 @@ func TestModelMetaRendersBelowInput(t *testing.T) { next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) rendered := next.(model).View() - inputIndex := strings.Index(rendered, "Message agentu") - modelIndex := strings.Index(rendered, "test-model") - if inputIndex < 0 || modelIndex < 0 { - t.Fatalf("rendered view missing input or model meta:\n%s", rendered) - } - if modelIndex < inputIndex { - t.Fatalf("model meta should render below input:\n%s", rendered) + if !strings.Contains(rendered, "test-model") { + t.Fatalf("rendered view missing %q:\n%s", "test-model", rendered) } } @@ -93,7 +86,7 @@ func TestModelMetaRendersContextUsage(t *testing.T) { assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("x", 800)}}) m := newModel(context.Background(), assistant, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) - rendered := next.(model).modelMetaView() + rendered := next.(model).footerView() for _, want := range []string{"ctx ~", "/1k", "%"} { if !strings.Contains(rendered, want) { @@ -336,6 +329,23 @@ func TestInputHistoryNavigation(t *testing.T) { } } +func TestUpArrowRecallsPreviousInput(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) + m.rememberInput("first") + m.rememberInput("second") + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = next.(model) + if got := m.input.Value(); got != "second" { + t.Fatalf("after first up input = %q, want %q", got, "second") + } + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = next.(model) + if got := m.input.Value(); got != "first" { + t.Fatalf("after second up input = %q, want %q", got, "first") + } +} + func TestToolMessageRendersStructuredCard(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`}) @@ -480,16 +490,60 @@ func runInDir(t *testing.T, dir string, name string, args ...string) { } } +func TestBangShellCommandRunsFromInput(t *testing.T) { + dir := t.TempDir() + m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true, WorkingDir: dir}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.input.SetValue("! printf hello") + next, _ = m.submit() + m = next.(model) + if m.input.Value() != "" { + t.Fatalf("input not cleared after bang command: %q", m.input.Value()) + } + rendered := stripANSI(m.renderMessages()) + if !strings.Contains(rendered, "$ printf hello") { + t.Fatalf("rendered output missing shell echo:\n%s", rendered) + } + if !strings.Contains(rendered, "hello") { + t.Fatalf("rendered output missing 'hello':\n%s", rendered) + } +} + +func TestBangShellCommandRequiresYolo(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: false}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.input.SetValue("! printf hello") + next, _ = m.submit() + m = next.(model) + rendered := stripANSI(m.renderMessages()) + if !strings.Contains(rendered, "shell command input requires --yolo") { + t.Fatalf("rendered output missing yolo requirement:\n%s", rendered) + } +} + +func TestBangShellCommandRejectsEmptyCommand(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.input.SetValue("!") + next, _ = m.submit() + m = next.(model) + rendered := stripANSI(m.renderMessages()) + if !strings.Contains(rendered, "usage: ! ") { + t.Fatalf("rendered output missing usage hint:\n%s", rendered) + } +} + func TestModelCommand(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) m.input.SetValue("/model") next, _ := m.submit() rendered := next.(model).renderMessages() - for _, want := range []string{"model: test-model", "mode: yolo tools", "theme: light"} { - if !strings.Contains(rendered, want) { - t.Fatalf("model command output missing %q:\n%s", want, rendered) - } + if !strings.Contains(rendered, "test-model") { + t.Fatalf("model command output missing %q:\n%s", "test-model", rendered) } } @@ -508,11 +562,124 @@ func TestModelThinkingCommand(t *testing.T) { } } +func TestModelCommandPaletteShowsConfiguredModels(t *testing.T) { + manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}} + m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.input.SetValue("/model ") + m.afterInputChanged() + plain := stripANSI(m.View()) + for _, want := range []string{iconCommand + " Model candidates", "model-a", "current model", "model-b", "switch model"} { + if !strings.Contains(plain, want) { + t.Fatalf("rendered palette missing %q:\n%s", want, plain) + } + } +} + +func TestModelCommandPaletteSelectsConfiguredModel(t *testing.T) { + manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}} + m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.input.SetValue("/model ") + m.afterInputChanged() + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = next.(model) + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = next.(model) + if manager.model != "model-b" { + t.Fatalf("model = %q", manager.model) + } + if got := m.input.Value(); got != "" { + t.Fatalf("input = %q", got) + } + rendered := stripANSI(m.renderMessages()) + if !strings.Contains(rendered, "model: model-b") { + t.Fatalf("rendered output missing model switch:\n%s", rendered) + } +} + +func TestModelCommandPaletteFiltersBareModelPartial(t *testing.T) { + manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}} + m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.input.SetValue("/model deep") + m.afterInputChanged() + palette := stripANSI(m.commandPaletteView()) + if !strings.Contains(palette, "deepseek-v4-flash") { + t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette) + } + if strings.Contains(palette, "claude-sonnet") { + t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette) + } +} + +func TestModelCommandPaletteFiltersExplicitModelSubcommand(t *testing.T) { + manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}} + m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.input.SetValue("/model model deep") + m.afterInputChanged() + palette := stripANSI(m.commandPaletteView()) + if !strings.Contains(palette, "deepseek-v4-flash") { + t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette) + } + if strings.Contains(palette, "claude-sonnet") { + t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette) + } +} + +func TestModelCommandPaletteTabFillsConfiguredModel(t *testing.T) { + manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}} + m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + m.input.SetValue("/model deep") + m.afterInputChanged() + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m = next.(model) + if got := m.input.Value(); got != "/model model deepseek-v4-flash" { + t.Fatalf("input = %q", got) + } + if manager.model != "claude-sonnet" { + t.Fatalf("model should not change on tab: %q", manager.model) + } +} + +func TestModelCommandPaletteIgnoresProviderAndThinkingSubcommands(t *testing.T) { + manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}} + m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + + m.input.SetValue("/model provider ") + m.afterInputChanged() + if sugs := m.commandSuggestions(); len(sugs) != 0 { + t.Fatalf("provider suggestions should be empty, got %d", len(sugs)) + } + if view := m.commandPaletteView(); view != "" { + t.Fatalf("provider palette should be empty:\n%s", view) + } + + m.input.SetValue("/model thinking ") + m.afterInputChanged() + if sugs := m.commandSuggestions(); len(sugs) != 0 { + t.Fatalf("thinking suggestions should be empty, got %d", len(sugs)) + } + if view := m.commandPaletteView(); view != "" { + t.Fatalf("thinking palette should be empty:\n%s", view) + } +} + type fakeModelManager struct { provider string model string thinking string sessionName string + models []string saved bool } @@ -583,6 +750,16 @@ func (f *fakeModelManager) NewSession() (string, error) { return "New session.", nil } +func (f *fakeModelManager) AvailableModels() []string { + if len(f.models) > 0 { + return append([]string(nil), f.models...) + } + if f.model != "" { + return []string{f.model} + } + return nil +} + func TestParseThemeMode(t *testing.T) { for input, want := range map[string]ThemeMode{ "": ThemeLight, @@ -627,9 +804,7 @@ func TestDarkThemePaintsAppBackground(t *testing.T) { next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) rendered := stripANSI(m.View()) - if !strings.Contains(rendered, "theme dark") { - t.Fatalf("rendered view missing 'theme dark':\n%s", rendered) - } + _ = rendered if m.theme.Mode != ThemeDark { t.Fatalf("theme.Mode = %q, want %q", m.theme.Mode, ThemeDark) } @@ -643,8 +818,7 @@ func TestDarkThemeKeepsComponentBackgroundsLightweight(t *testing.T) { next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) requireNoStyleColor(t, "Viewport.Background", m.styles.Viewport.GetBackground()) - requireNoStyleColor(t, "InputBox.Background", m.styles.InputBox.GetBackground()) - requireNoStyleColor(t, "AssistantMsg.Background", m.styles.AssistantMsg.GetBackground()) + requireStyleColor(t, "InputBox.Background", m.styles.InputBox.GetBackground(), darkTheme().InputBg) requireNoStyleColor(t, "Activity.Background", m.styles.Activity.GetBackground()) m.running = true m.status = "thinking..." @@ -662,16 +836,34 @@ func TestDarkThemeStylesUseDarkPalette(t *testing.T) { requireStyleColor(t, "Pill.Background", m.styles.Pill.GetBackground(), want.SurfaceAlt) requireStyleColor(t, "input.FocusedStyle.Base.Foreground", m.input.FocusedStyle.Base.GetForeground(), want.Text) requireStyleColor(t, "input.FocusedStyle.Placeholder.Foreground", m.input.FocusedStyle.Placeholder.GetForeground(), want.Muted) + requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), want.Error) + requireStyleColor(t, "InputModeCommand.Foreground", m.styles.InputModeCommand.GetForeground(), want.User) } -func TestModelCommandShowsDarkTheme(t *testing.T) { - m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark}) - m.input.SetValue("/model") - next, _ := m.submit() - rendered := next.(model).renderMessages() - if !strings.Contains(rendered, "theme: dark") { - t.Fatalf("model command output missing 'theme: dark':\n%s", rendered) +func TestInputModeSymbolReflectsYoloAndBangCommand(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = next.(model) + + rendered := stripANSI(m.inputView()) + if !strings.Contains(rendered, "Y") { + t.Fatalf("YOLO input view should contain Y symbol:\n%s", rendered) } + + m.input.SetValue("! pwd") + m.afterInputChanged() + rendered = stripANSI(m.inputView()) + if !strings.Contains(rendered, iconCommand) { + t.Fatalf("bang input view should contain command icon:\n%s", rendered) + } + if strings.Contains(rendered, "Y") { + t.Fatalf("bang input view should not contain Y symbol:\n%s", rendered) + } +} + +func TestInputModeYoloUsesWarningColor(t *testing.T) { + m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) + requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), lightTheme().Error) } func TestThemeSlashCommandSwitchesAtRuntime(t *testing.T) { diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 6f53588..b98183d 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -43,6 +43,7 @@ type theme struct { Background string Surface string SurfaceAlt string + InputBg string Border string Text string Muted string @@ -56,35 +57,37 @@ type theme struct { } type styles struct { - App lipgloss.Style - Muted lipgloss.Style - Activity lipgloss.Style - Header lipgloss.Style - ModelMeta lipgloss.Style - Footer lipgloss.Style - Viewport lipgloss.Style - InputBox lipgloss.Style - CommandHint lipgloss.Style - StatusReady lipgloss.Style - StatusWarn lipgloss.Style - StatusError lipgloss.Style - StatusInfo lipgloss.Style - Pill lipgloss.Style - PillAccent lipgloss.Style - Palette lipgloss.Style - PaletteMatch lipgloss.Style - ToolName lipgloss.Style - ToolKey lipgloss.Style - ToolValue lipgloss.Style - UserLabel lipgloss.Style - ToolLabel lipgloss.Style - ErrorLabel lipgloss.Style - SystemLabel lipgloss.Style - UserMessage lipgloss.Style - AssistantMsg lipgloss.Style - ToolMessage lipgloss.Style - ErrorMessage lipgloss.Style - SystemMessage lipgloss.Style + App lipgloss.Style + Muted lipgloss.Style + Activity lipgloss.Style + Header lipgloss.Style + Footer lipgloss.Style + Viewport lipgloss.Style + InputBox lipgloss.Style + CommandHint lipgloss.Style + InputModeReadonly lipgloss.Style + InputModeYolo lipgloss.Style + InputModeCommand lipgloss.Style + StatusReady lipgloss.Style + StatusWarn lipgloss.Style + StatusError lipgloss.Style + StatusInfo lipgloss.Style + Pill lipgloss.Style + PillAccent lipgloss.Style + Palette lipgloss.Style + PaletteMatch lipgloss.Style + ToolName lipgloss.Style + ToolKey lipgloss.Style + ToolValue lipgloss.Style + UserLabel lipgloss.Style + ToolLabel lipgloss.Style + ErrorLabel lipgloss.Style + SystemLabel lipgloss.Style + UserMessage lipgloss.Style + AssistantMsg lipgloss.Style + ToolMessage lipgloss.Style + ErrorMessage lipgloss.Style + SystemMessage lipgloss.Style } func themeForMode(mode ThemeMode) theme { @@ -98,8 +101,8 @@ func lightTheme() theme { return theme{ Mode: ThemeLight, Background: "#F8FAFC", - Surface: "#FFFFFF", SurfaceAlt: "#EEF2F7", + InputBg: "#E4E4E4", Border: "#CBD5E1", Text: "#111827", Muted: "#64748B", @@ -116,8 +119,8 @@ func darkTheme() theme { return theme{ Mode: ThemeDark, Background: "#111827", - Surface: "#1F2937", SurfaceAlt: "#0F172A", + InputBg: "#383838", Border: "#475569", Text: "#E5E7EB", Muted: "#94A3B8", @@ -153,10 +156,6 @@ func (t theme) styles() styles { Bold(true). Foreground(lipgloss.Color(t.Title)), - ModelMeta: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.Muted)). - Padding(0, 1), - Footer: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)). Padding(0, 1), @@ -166,14 +165,31 @@ func (t theme) styles() styles { InputBox: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Text)). - Border(lipgloss.ThickBorder()). - BorderForeground(lipgloss.Color(t.Border)). - Padding(0, 2), + Background(lipgloss.Color(t.InputBg)). + Padding(1, 2, 1, 1), CommandHint: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)). Padding(0, 1), + InputModeReadonly: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(t.Muted)). + Background(lipgloss.Color(t.InputBg)). + Padding(0, 1), + + InputModeYolo: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(t.Error)). + Background(lipgloss.Color(t.InputBg)). + Padding(0, 1), + + InputModeCommand: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(t.User)). + Background(lipgloss.Color(t.InputBg)). + Padding(0, 1), + StatusReady: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Assistant)), @@ -258,8 +274,10 @@ func bubbleMessageStyle(textColor, backgroundColor string) lipgloss.Style { func applyTextareaTheme(input *textarea.Model, t theme) { focused := textarea.Style{ Base: lipgloss.NewStyle(). - Foreground(lipgloss.Color(t.Text)), - CursorLine: lipgloss.NewStyle(), + Foreground(lipgloss.Color(t.Text)). + Background(lipgloss.Color(t.InputBg)), + CursorLine: lipgloss.NewStyle(). + Background(lipgloss.Color(t.InputBg)), Placeholder: lipgloss.NewStyle(). Foreground(lipgloss.Color(t.Muted)), Prompt: lipgloss.NewStyle(). @@ -270,7 +288,7 @@ func applyTextareaTheme(input *textarea.Model, t theme) { Foreground(lipgloss.Color(t.Surface)), } blurred := focused - blurred.CursorLine = lipgloss.NewStyle() + blurred.CursorLine = lipgloss.NewStyle().Background(lipgloss.Color(t.InputBg)) input.FocusedStyle = focused input.BlurredStyle = blurred