agent: update chat tui controls

This commit is contained in:
ParthSareen 2026-06-12 14:13:10 -07:00
parent 50184444da
commit 676facf043
12 changed files with 262 additions and 134 deletions

View file

@ -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:

View file

@ -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 {

View file

@ -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))

View file

@ -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())
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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 {
"- `/<skill>`: 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) != "" {

View file

@ -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, "- `/<skill>`: 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")}

View file

@ -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."))

View file

@ -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) {

View file

@ -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"

View file

@ -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)
}
}