From 676facf0436c4e50d00f97168da530f22eeeb769 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Fri, 12 Jun 2026 14:13:10 -0700 Subject: [PATCH] agent: update chat tui controls --- agent/approval.go | 4 +- agent/tools/file.go | 85 +------------------------------------ cmd/agent_tui.go | 24 +++++++++-- cmd/agent_tui_test.go | 21 ++++++++- cmd/cmd_test.go | 12 +++++- cmd/tui/chat.go | 33 +++++--------- cmd/tui/chat_input.go | 27 +++++++++++- cmd/tui/chat_input_test.go | 78 ++++++++++++++++++++++++++++++++++ cmd/tui/chat_modals.go | 3 -- cmd/tui/chat_modals_test.go | 3 ++ cmd/tui/chat_render.go | 44 +++++++++++++++++-- cmd/tui/chat_render_test.go | 62 ++++++++++++++++++++++----- 12 files changed, 262 insertions(+), 134 deletions(-) diff --git a/agent/approval.go b/agent/approval.go index 045c8eb57..75efcc037 100644 --- a/agent/approval.go +++ b/agent/approval.go @@ -251,7 +251,7 @@ type DefaultApprovalPolicy struct{} func (DefaultApprovalPolicy) EvaluateApproval(_ context.Context, req ApprovalRequest) ApprovalEvaluation { switch req.ToolName { - case "read", "list": + case "read": if path, ok := stringApprovalArg(req.Args, "path"); ok { if reason := approvalPathEscapeReason(req.WorkingDir, path); reason != "" { return denyApproval(req.ToolName, ApprovalRiskHigh, reason) @@ -613,8 +613,6 @@ func toolApprovalDisplayName(name string) string { return "Bash" case "read": return "Read" - case "list": - return "List" case "edit": return "Edit" default: diff --git a/agent/tools/file.go b/agent/tools/file.go index a0227600f..68baacd5b 100644 --- a/agent/tools/file.go +++ b/agent/tools/file.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "github.com/ollama/ollama/agent" @@ -13,8 +12,7 @@ import ( ) const ( - maxReadBytes = 200000 - maxListEntries = 300 + maxReadBytes = 200000 ) type Read struct{} @@ -83,87 +81,6 @@ func (r *Read) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[ return agent.ToolResult{Content: string(content)}, nil } -type List struct{} - -func NewList() *List { - return &List{} -} - -func (l *List) Name() string { - return "list" -} - -func (l *List) Description() string { - return "List files and directories in the current working directory." -} - -func (l *List) Schema() api.ToolFunction { - props := api.NewToolPropertiesMap() - props.Set("path", api.ToolProperty{ - Type: api.PropertyType{"string"}, - Description: "Directory path to list, relative to the working directory. Defaults to the working directory.", - }) - return api.ToolFunction{ - Name: l.Name(), - Description: l.Description(), - Parameters: api.ToolFunctionParameters{ - Type: "object", - Properties: props, - }, - } -} - -func (l *List) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) { - path := "." - if raw, ok := args["path"].(string); ok && strings.TrimSpace(raw) != "" { - path = raw - } - - resolved, err := resolvePath(toolCtx.WorkingDir, path) - if err != nil { - return agent.ToolResult{}, err - } - - entries, err := os.ReadDir(resolved) - if err != nil { - return agent.ToolResult{}, err - } - sort.Slice(entries, func(i, j int) bool { - if entries[i].IsDir() != entries[j].IsDir() { - return entries[i].IsDir() - } - return entries[i].Name() < entries[j].Name() - }) - - var sb strings.Builder - count := len(entries) - limit := count - if limit > maxListEntries { - limit = maxListEntries - } - for i := 0; i < limit; i++ { - select { - case <-ctx.Done(): - return agent.ToolResult{}, ctx.Err() - default: - } - - name := entries[i].Name() - if entries[i].IsDir() { - name += string(os.PathSeparator) - } - sb.WriteString(name) - sb.WriteByte('\n') - } - if count > limit { - sb.WriteString(fmt.Sprintf("... (%d entries omitted)\n", count-limit)) - } - if sb.Len() == 0 { - return agent.ToolResult{Content: "(empty directory)"}, nil - } - return agent.ToolResult{Content: sb.String()}, nil -} - type Edit struct{} func NewEdit() *Edit { diff --git a/cmd/agent_tui.go b/cmd/agent_tui.go index 462596829..dbf667d69 100644 --- a/cmd/agent_tui.go +++ b/cmd/agent_tui.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "syscall" + "time" "github.com/google/uuid" "github.com/spf13/cobra" @@ -424,8 +425,6 @@ func agentHeadlessToolLabel(name string, args map[string]any) string { displayName = "Bash" case "read": displayName = "Read" - case "list": - displayName = "List" case "edit": displayName = "Edit" } @@ -451,7 +450,12 @@ func loadAgentSkills() *skills.Catalog { } func agentSystemPrompt(catalog *skills.Catalog, skillToolAvailable bool, extra string) string { + return agentSystemPromptAt(time.Now(), catalog, skillToolAvailable, extra) +} + +func agentSystemPromptAt(now time.Time, catalog *skills.Catalog, skillToolAvailable bool, extra string) string { var parts []string + parts = append(parts, agentDefaultSystemPrompt(now)) if catalogPrompt := catalog.SystemPrompt(skillToolAvailable); strings.TrimSpace(catalogPrompt) != "" { parts = append(parts, catalogPrompt) } @@ -461,6 +465,21 @@ func agentSystemPrompt(catalog *skills.Catalog, skillToolAvailable bool, extra s return strings.Join(parts, "\n\n") } +func agentDefaultSystemPrompt(now time.Time) string { + date := now.Format("Monday, January 2, 2006") + return strings.Join([]string{ + "You are Ollama, a helpful local AI assistant.", + "", + "Current date: " + date + ".", + "", + "Be concise, practical, and action-oriented. Use tools when they materially help. Verify current or fast-changing facts with web tools when available; otherwise state uncertainty.", + "", + "Use bash carefully. Prefer read-only inspection first. Stay within the current working directory unless explicitly asked. Surface intent before risky actions such as writes, deletes, moves, installs, git state changes, service changes, sudo, secrets access, network scripts, or commands outside the working directory. Request approval when required and do not work around denied approvals.", + "", + "Tell the user about meaningful changes, verification, failures, blockers, assumptions, and risks. Summarize routine tool output instead of dumping it.", + }, "\n") +} + func agentModelOptions(ctx context.Context, client *api.Client) ([]tui.ChatModelOption, error) { if client == nil { return nil, errors.New("model picker requires an API client") @@ -564,7 +583,6 @@ func agentToolsRegistry(ctx context.Context, client *api.Client, modelName strin registry.Register(agenttools.NewBash()) } registry.Register(agenttools.NewRead()) - registry.Register(agenttools.NewList()) registry.Register(agenttools.NewEdit()) if !catalog.Empty() { registry.Register(agenttools.NewSkill(catalog)) diff --git a/cmd/agent_tui_test.go b/cmd/agent_tui_test.go index 153322759..f799bf5b7 100644 --- a/cmd/agent_tui_test.go +++ b/cmd/agent_tui_test.go @@ -7,7 +7,9 @@ import ( "net/http/httptest" "net/url" "slices" + "strings" "testing" + "time" "github.com/ollama/ollama/agent/skills" "github.com/ollama/ollama/api" @@ -15,6 +17,20 @@ import ( modelpkg "github.com/ollama/ollama/types/model" ) +func TestAgentSystemPromptFormatsCurrentDate(t *testing.T) { + prompt := agentSystemPromptAt(time.Date(2026, time.June, 12, 9, 30, 0, 0, time.UTC), nil, false, "") + for _, want := range []string{ + "You are Ollama, a helpful local AI assistant.", + "Current date: Friday, June 12, 2026.", + "Be concise, practical, and action-oriented.", + "Use bash carefully.", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q:\n%s", want, prompt) + } + } +} + func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) { t.Setenv("OLLAMA_NO_CLOUD", "1") t.Setenv("OLLAMA_AGENT_DISABLE_BASH", "") @@ -44,9 +60,12 @@ func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) { if registry == nil { t.Fatal("registry = nil, want local tools") } - if !registry.Has("bash") || !registry.Has("read") || !registry.Has("list") || !registry.Has("edit") { + if !registry.Has("bash") || !registry.Has("read") || !registry.Has("edit") { t.Fatalf("local tools missing: %v", registry.Names()) } + if registry.Has("list") { + t.Fatalf("list tool should not be registered; got %v", registry.Names()) + } if registry.Has("web_search") || registry.Has("web_fetch") { t.Fatalf("web tools should be disabled when OLLAMA_NO_CLOUD is set: %v", registry.Names()) } diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 5a19cb36f..984fd341b 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -665,7 +665,11 @@ func TestRunHandlerHeadlessAgent(t *testing.T) { if chatReq.Model != "test-model" { t.Fatalf("chat model = %q, want test-model", chatReq.Model) } - if len(chatReq.Messages) != 1 || chatReq.Messages[0].Role != "user" || chatReq.Messages[0].Content != "hello" { + if len(chatReq.Messages) != 2 || + chatReq.Messages[0].Role != "system" || + !strings.Contains(chatReq.Messages[0].Content, "You are Ollama, a helpful local AI assistant.") || + chatReq.Messages[1].Role != "user" || + chatReq.Messages[1].Content != "hello" { t.Fatalf("chat messages = %#v", chatReq.Messages) } } @@ -736,7 +740,11 @@ func TestRunHandlerPromptUsesAgentLoopByDefault(t *testing.T) { if chatReq.Model != "test-model" { t.Fatalf("chat model = %q, want test-model", chatReq.Model) } - if len(chatReq.Messages) != 1 || chatReq.Messages[0].Role != "user" || chatReq.Messages[0].Content != "hello" { + if len(chatReq.Messages) != 2 || + chatReq.Messages[0].Role != "system" || + !strings.Contains(chatReq.Messages[0].Content, "You are Ollama, a helpful local AI assistant.") || + chatReq.Messages[1].Role != "user" || + chatReq.Messages[1].Content != "hello" { t.Fatalf("chat messages = %#v", chatReq.Messages) } } diff --git a/cmd/tui/chat.go b/cmd/tui/chat.go index 280938df3..7950696b4 100644 --- a/cmd/tui/chat.go +++ b/cmd/tui/chat.go @@ -204,14 +204,15 @@ type chatModel struct { reviewApproval coreagent.ApprovalHandler permissionMode *chatPermissionMode - width int - height int - status string - spinner int - complete int - quitting bool - quitArmed bool - err error + width int + height int + status string + spinner int + complete int + systemPromptDisabled bool + quitting bool + quitArmed bool + err error } func RunAgentChat(ctx context.Context, opts ChatOptions) (*ChatResult, error) { @@ -243,7 +244,7 @@ func RunAgentChat(ctx context.Context, opts ChatOptions) (*ChatResult, error) { m.contextTokens = m.estimatePromptTokens(m.messages, "") m.contextEstimate = true - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithReportFocus()) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithReportFocus(), tea.WithMouseCellMotion()) finalModel, err := p.Run() if err != nil { return nil, err @@ -421,10 +422,6 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.insertInputNewline() return m, nil case tea.KeyUp: - if m.running || m.compacting { - m.scrollBy(1) - return m, nil - } if m.promptActive && m.movePromptHistory(-1) { return m, nil } @@ -434,23 +431,15 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.movePromptHistory(-1) { return m, nil } - m.scrollBy(1) return m, nil case tea.KeyDown: - if m.running || m.compacting { - m.scrollBy(-1) - return m, nil - } if m.promptActive && m.movePromptHistory(1) { return m, nil } if m.moveCompletion(1) { return m, nil } - if m.movePromptHistory(1) { - return m, nil - } - m.scrollBy(-1) + m.movePromptHistory(1) return m, nil case tea.KeyPgUp: m.scrollBy(m.transcriptHeight() - 1) diff --git a/cmd/tui/chat_input.go b/cmd/tui/chat_input.go index 7b7cb76a8..7298b60ee 100644 --- a/cmd/tui/chat_input.go +++ b/cmd/tui/chat_input.go @@ -43,6 +43,7 @@ var chatSlashCommands = []chatSlashCommand{ {name: "/skills", description: "show or import installed skills"}, {name: "/new", description: "start a new chat"}, {name: "/resume", description: "resume a saved chat"}, + {name: "/system", description: "toggle or set system prompt"}, {name: "/think", description: "set thinking mode"}, {name: "/verbose", description: "toggle model metrics"}, {name: "/compact", description: "summarize older context"}, @@ -124,6 +125,8 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) { return m.resetChat("new chat") case input == "/resume": return m.openResumePicker() + case input == "/system" || strings.HasPrefix(input, "/system "): + return m.handleSystemCommand(input) case input == "/verbose" || strings.HasPrefix(input, "/verbose "): return m.handleVerboseCommand(input) case input == "/compact": @@ -144,6 +147,23 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) { return m.startRun(input) } +func (m *chatModel) handleSystemCommand(input string) (tea.Model, tea.Cmd) { + arg := strings.TrimSpace(strings.TrimPrefix(input, "/system")) + if arg == "" { + m.systemPromptDisabled = !m.systemPromptDisabled + state := "on" + if m.systemPromptDisabled { + state = "off" + } + m.status = "cache will break by turning system prompt " + state + return *m, nil + } + m.opts.SystemPrompt = arg + m.systemPromptDisabled = false + m.status = "system prompt set; cache will break" + return *m, nil +} + func (m *chatModel) handleVerboseCommand(input string) (tea.Model, tea.Cmd) { arg := strings.TrimSpace(strings.TrimPrefix(input, "/verbose")) switch strings.ToLower(arg) { @@ -601,6 +621,7 @@ func (m chatModel) helpSummary() string { "- `/`: run the next message with a skill", "- `/new`: start a new chat", "- `/resume`: resume a saved chat", + "- `/system`: toggle or set system prompt", "- `/verbose`: toggle model metrics", "- `/compact`: summarize older context", "- `/clear`: clear this chat", @@ -611,7 +632,9 @@ func (m chatModel) helpSummary() string { "- `ctrl+o`: toggle tool output and details", "- `shift+enter`: insert a newline", "- `shift+tab`: toggle permission mode", - "- `↑/↓`, `pgup/pgdn`, `home/end`: scroll transcript", + "- `↑/↓`: previous or next prompt", + "- `mouse wheel`, `pgup/pgdn`, `home/end`: scroll transcript", + "- `shift+drag`: select text to copy (`option+drag` in iTerm2)", }, "\n") } @@ -901,7 +924,7 @@ func (m *chatModel) importSkills(args []string) string { func (m chatModel) systemPrompt(extra string) string { var parts []string - if strings.TrimSpace(m.opts.SystemPrompt) != "" { + if !m.systemPromptDisabled && strings.TrimSpace(m.opts.SystemPrompt) != "" { parts = append(parts, strings.TrimSpace(m.opts.SystemPrompt)) } if strings.TrimSpace(extra) != "" { diff --git a/cmd/tui/chat_input_test.go b/cmd/tui/chat_input_test.go index 87ff75985..1e2f1cf3a 100644 --- a/cmd/tui/chat_input_test.go +++ b/cmd/tui/chat_input_test.go @@ -62,6 +62,7 @@ func TestChatHelpCommandShowsCommands(t *testing.T) { !strings.Contains(fm.entries[0].content, "- `/think`: set thinking mode") || !strings.Contains(fm.entries[0].content, "- `/history`: show prompt message history") || !strings.Contains(fm.entries[0].content, "- `/verbose`: toggle model metrics") || + !strings.Contains(fm.entries[0].content, "- `/system`: toggle or set system prompt") || !strings.Contains(fm.entries[0].content, "- `/`: run the next message with a skill") || !strings.Contains(fm.entries[0].content, "**Shortcuts**") || !strings.Contains(fm.entries[0].content, "- `ctrl+o`: toggle tool output and details") || @@ -330,6 +331,17 @@ func TestChatSlashCommandSuggestionsIncludeThink(t *testing.T) { } } +func TestChatSlashCommandSuggestionsIncludeSystem(t *testing.T) { + m := chatModel{ + input: []rune("/sys"), + } + + lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n")) + if !strings.Contains(lines, "/system") || !strings.Contains(lines, "toggle or set system prompt") { + t.Fatalf("suggestions missing /system: %q", lines) + } +} + func TestChatSlashCommandSuggestionsHideLegacySetThink(t *testing.T) { m := chatModel{ input: []rune("/set"), @@ -562,6 +574,72 @@ func TestChatVerboseCommandRejectsInvalidState(t *testing.T) { } } +func TestChatSystemCommandTogglesPrompt(t *testing.T) { + m := chatModel{ + input: []rune("/system"), + opts: ChatOptions{SystemPrompt: "base prompt"}, + } + + updated, cmd := m.handleSubmit() + if cmd != nil { + t.Fatal("system command should not return a command") + } + m = updated.(chatModel) + if !m.systemPromptDisabled { + t.Fatal("system prompt should be disabled") + } + if got := m.systemPrompt(""); got != "" { + t.Fatalf("system prompt = %q, want empty", got) + } + if m.status != "cache will break by turning system prompt off" { + t.Fatalf("status = %q", m.status) + } + if footer := m.footerLine(); !strings.Contains(footer, "cache will break by turning system prompt off") { + t.Fatalf("footer missing cache warning: %q", footer) + } + + m.input = []rune("/system") + updated, cmd = m.handleSubmit() + if cmd != nil { + t.Fatal("system command should not return a command") + } + m = updated.(chatModel) + if m.systemPromptDisabled { + t.Fatal("system prompt should be enabled") + } + if got := m.systemPrompt(""); got != "base prompt" { + t.Fatalf("system prompt = %q, want base prompt", got) + } + if m.status != "cache will break by turning system prompt on" { + t.Fatalf("status = %q", m.status) + } +} + +func TestChatSystemCommandSetsPrompt(t *testing.T) { + m := chatModel{ + input: []rune("/system You are concise."), + systemPromptDisabled: true, + } + + updated, cmd := m.handleSubmit() + if cmd != nil { + t.Fatal("system command should not return a command") + } + m = updated.(chatModel) + if m.systemPromptDisabled { + t.Fatal("setting system prompt should enable it") + } + if got := m.opts.SystemPrompt; got != "You are concise." { + t.Fatalf("stored system prompt = %q", got) + } + if got := m.systemPrompt(""); got != "You are concise." { + t.Fatalf("system prompt = %q", got) + } + if m.status != "system prompt set; cache will break" { + t.Fatalf("status = %q", m.status) + } +} + func TestChatLegacySetVerboseQuietCommandsStaySupported(t *testing.T) { m := chatModel{input: []rune("/set verbose")} diff --git a/cmd/tui/chat_modals.go b/cmd/tui/chat_modals.go index dcf8b2e33..2fb5f6c1e 100644 --- a/cmd/tui/chat_modals.go +++ b/cmd/tui/chat_modals.go @@ -511,9 +511,6 @@ func (m chatModel) renderHistoryPopup(width, height int) string { var b strings.Builder b.WriteString(chatResumeTitleStyle.Render("Message history")) - if len(bodyLines) > visibleHeight { - b.WriteString(chatResumeMetaStyle.Render(fmt.Sprintf(" %d/%d", scroll+1, maxScroll+1))) - } b.WriteString("\n\n") if len(bodyLines) == 0 { b.WriteString(chatResumeMetaStyle.Render("No messages yet.")) diff --git a/cmd/tui/chat_modals_test.go b/cmd/tui/chat_modals_test.go index e6bc3f5a2..08b2fed07 100644 --- a/cmd/tui/chat_modals_test.go +++ b/cmd/tui/chat_modals_test.go @@ -144,6 +144,9 @@ func TestChatHistoryCommandStartsAtBottom(t *testing.T) { if strings.Contains(view, "prompt 0") { t.Fatalf("history popup started at oldest messages:\n%s", view) } + if title := strings.Split(view, "\n")[0]; strings.Contains(title, "/") { + t.Fatalf("history popup should not show scroll counter in title:\n%s", view) + } } func TestChatHistoryCommandFormatsMultilineContentWithLabel(t *testing.T) { diff --git a/cmd/tui/chat_render.go b/cmd/tui/chat_render.go index aee522199..ac3bd1c07 100644 --- a/cmd/tui/chat_render.go +++ b/cmd/tui/chat_render.go @@ -276,13 +276,22 @@ func (m chatModel) bottomLines(width, maxHeight int) []string { if activity := m.activityLine(); activity != "" { lines = append(lines, chatMetaStyle.Render(activity)) } - fixedLines := len(lines) + 3 + + footerLines := m.renderFooterLines(width) + if maxHeight > 0 { + maxFooterLines := max(1, maxHeight-len(lines)-3) + if len(footerLines) > maxFooterLines { + footerLines = footerLines[:maxFooterLines] + } + } + + fixedLines := len(lines) + len(footerLines) + 2 inputBodyLines := maxInputBoxBodyLines if maxHeight > 0 { inputBodyLines = min(inputBodyLines, max(1, maxHeight-fixedLines)) } lines = append(lines, renderInputBoxLines(string(m.input), width, inputBodyLines)...) - lines = append(lines, m.renderFooterLine()) + lines = append(lines, footerLines...) return lines } @@ -978,8 +987,6 @@ func (m chatModel) footerParts() []string { controls := action if m.approvalPrompt != nil { controls += " • ←/→ choose • o once • s session • d deny • esc deny" - } else { - controls += " • ctrl+c clear/cancel/quit" } controls += " • shift+tab" if m.lastExpandableToolEntry() >= 0 { @@ -1011,6 +1018,35 @@ func (m chatModel) renderFooterLine() string { return strings.Join(parts, chatMetaStyle.Render(" • ")) } +func (m chatModel) renderFooterLines(width int) []string { + lines := wrapChatText(m.footerLine(), width) + for i, line := range lines { + lines[i] = renderFooterPlainLine(line) + } + return lines +} + +func renderFooterPlainLine(line string) string { + const fullAccess = "full access" + if !strings.Contains(line, fullAccess) { + return chatMetaStyle.Render(line) + } + + var b strings.Builder + for { + before, after, ok := strings.Cut(line, fullAccess) + if before != "" { + b.WriteString(chatMetaStyle.Render(before)) + } + if !ok { + break + } + b.WriteString(chatFullAccessStyle.Render(fullAccess)) + line = after + } + return b.String() +} + func (m chatModel) permissionModeStatus() string { if m.autoApproveTools() { return "full access" diff --git a/cmd/tui/chat_render_test.go b/cmd/tui/chat_render_test.go index cf5d94b4d..cbd929b91 100644 --- a/cmd/tui/chat_render_test.go +++ b/cmd/tui/chat_render_test.go @@ -125,7 +125,7 @@ func TestChatViewCapsTallInputBoxAndKeepsFooter(t *testing.T) { if got := inputBoxBodyLineCount(t, view, 32); got > maxInputBoxBodyLines { t.Fatalf("input body lines = %d, want <= %d:\n%s", got, maxInputBoxBodyLines, view) } - if !strings.Contains(view, "enter send") || !strings.Contains(view, "ctrl+c clear/cancel") { + if !strings.Contains(view, "enter send") || !strings.Contains(view, "shift+tab") || !strings.Contains(view, "review") { t.Fatalf("view should keep footer visible:\n%s", view) } if !strings.Contains(view, "> ... ") { @@ -133,6 +133,45 @@ func TestChatViewCapsTallInputBoxAndKeepsFooter(t *testing.T) { } } +func TestChatViewWrapsFooterWhenNarrow(t *testing.T) { + m := chatModel{ + input: []rune("hello"), + width: 28, + height: 14, + status: "cache will break by turning system prompt off", + permissionMode: newChatPermissionMode(true), + opts: ChatOptions{ + Verbose: true, + ContextWindowTokens: 262144, + }, + contextTokens: 12345, + contextEstimate: true, + } + + view := stripANSI(m.View()) + if got := len(strings.Split(view, "\n")); got != 14 { + t.Fatalf("view height = %d, want 14:\n%s", got, view) + } + for _, want := range []string{ + "cache will break by", + "system prompt off", + "enter", + "send", + "full access", + "ctx", + "~12,345/262,144", + } { + if !strings.Contains(view, want) { + t.Fatalf("wrapped footer missing %q:\n%s", want, view) + } + } + for _, line := range strings.Split(view, "\n") { + if len([]rune(line)) > 28 { + t.Fatalf("line width = %d, want <= 28: %q\n%s", len([]rune(line)), line, view) + } + } +} + func inputBoxBodyLineCount(t *testing.T, view string, width int) int { t.Helper() border := strings.Repeat("─", width) @@ -271,10 +310,10 @@ func TestChatScrollsTranscript(t *testing.T) { t.Fatalf("scrollStatus = %q, want ↑ more", got) } - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) m = updated.(chatModel) - if m.scroll != 1 { - t.Fatalf("scroll = %d, want 1", m.scroll) + if m.scroll == 0 { + t.Fatal("page up should scroll transcript") } if got := m.scrollStatus(); got != "↑/↓ more" { t.Fatalf("scrollStatus = %q, want ↑/↓ more", got) @@ -328,7 +367,7 @@ func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) { } } -func TestChatArrowKeysScrollTranscriptWhileRunning(t *testing.T) { +func TestChatArrowKeysNavigatePromptHistoryWhenTranscriptScrollable(t *testing.T) { m := chatModel{ input: []rune("queued draft"), promptHistory: []string{"old prompt"}, @@ -345,17 +384,20 @@ func TestChatArrowKeysScrollTranscriptWhileRunning(t *testing.T) { updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) m = updated.(chatModel) - if m.scroll != 1 { - t.Fatalf("key up should scroll transcript while running, got %d", m.scroll) + if m.scroll != 0 { + t.Fatalf("key up should not scroll transcript, got %d", m.scroll) } - if got := string(m.input); got != "queued draft" { - t.Fatalf("key up should not recall prompt history while running, got input %q", got) + if got := string(m.input); got != "old prompt" { + t.Fatalf("key up should recall prompt history, got %q", got) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) m = updated.(chatModel) if m.scroll != 0 { - t.Fatalf("key down should return to bottom while running, got %d", m.scroll) + t.Fatalf("key down should not scroll transcript, got %d", m.scroll) + } + if got := string(m.input); got != "queued draft" { + t.Fatalf("key down should restore draft, got %q", got) } }