diff --git a/cmd/tui/chat/approval_test.go b/cmd/tui/chat/approval_test.go index 69f4acbaa..184641adc 100644 --- a/cmd/tui/chat/approval_test.go +++ b/cmd/tui/chat/approval_test.go @@ -36,7 +36,7 @@ func TestChatApprovalPromptRendersAndApprovesOnce(t *testing.T) { !strings.Contains(view, "›") { t.Fatalf("approval view missing content: %q", view) } - if strings.Contains(view, "█") { + if strings.Contains(view, inputCursorGlyph) { t.Fatalf("approval view should hide the input cursor: %q", view) } if strings.Contains(view, "1/2/3 choose • enter select • esc deny") { diff --git a/cmd/tui/chat/chat.go b/cmd/tui/chat/chat.go index c7bc12780..7ba51ec87 100644 --- a/cmd/tui/chat/chat.go +++ b/cmd/tui/chat/chat.go @@ -293,7 +293,6 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { if isUnsupportedThinkingError(msg.err) && thinkRequestsThinking(m.opts.Think) { m.opts.Think = &api.ThinkValue{Value: false} - m.status = fmt.Sprintf("Thinking disabled for %s", msg.model) if msg.model != "" && m.opts.PreloadModel != nil { m.preloadingModel = msg.model return m, tea.Batch(preloadModelCmd(m.ctx, m.opts.PreloadModel, msg.model, m.opts.Think), m.scheduleTick()) @@ -322,6 +321,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case chatRunDoneMsg: wasCanceling := m.status == "canceling" + m.finishThinkingEntry() m.running = false m.awaitingModel = false m.compacting = false @@ -500,14 +500,12 @@ func (m *chatModel) finishTranscriptSelection(msg tea.MouseMsg) { func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyCtrlO { - m.toggleInlineToolOutput() m.disarmQuit() - // Toggling tool output changes the transcript height; force a full - // repaint so the input box and model-status footer aren't left stale. + m.toggleInlineToolOutput() if m.boundedFrame { return m, tea.ClearScreen } - return m.withFlowTranscriptRepaint(nil) + return m.withFlowTranscriptReplace(nil) } if msg.Type == tea.KeyShiftTab { return m.togglePermissionMode() @@ -552,14 +550,26 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyCtrlG: return m.openInputEditor() + case tea.KeyCtrlP: + return m.updateUpKey() + case tea.KeyCtrlN: + return m.updateDownKey() case tea.KeyUp: return m.updateUpKey() case tea.KeyDown: return m.updateDownKey() case tea.KeyLeft: - m.moveInputCursorHorizontal(-1) + if msg.Alt { + m.moveInputCursorWord(-1) + } else { + m.moveInputCursorHorizontal(-1) + } case tea.KeyRight: - m.moveInputCursorHorizontal(1) + if msg.Alt { + m.moveInputCursorWord(1) + } else { + m.moveInputCursorHorizontal(1) + } case tea.KeyCtrlA: m.moveInputCursorToLineStart() case tea.KeyCtrlE: @@ -584,7 +594,7 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.scroll = m.maxScroll() case tea.KeyCtrlEnd: m.scroll = 0 - case tea.KeyBackspace: + case tea.KeyBackspace, tea.KeyCtrlH: m.resetPromptHistoryCursor() if msg.Alt { m.deleteInputWordBackward() @@ -600,12 +610,17 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyCtrlK: m.resetPromptHistoryCursor() m.deleteInputForward() + case tea.KeyDelete: + m.resetPromptHistoryCursor() + m.deleteInputForward() case tea.KeyTab: m.applyCompletion() case tea.KeySpace: m.insertInputRunes([]rune{' '}) case tea.KeyRunes: - m.insertInputRunesFromKey(msg.Runes, msg.Paste) + if !msg.Alt || !m.handleInputAltRunes(msg.Runes) { + m.insertInputRunesFromKey(msg.Runes, msg.Paste) + } default: if m.canEditInput() && isShiftEnterCSI(msg) { m.insertInputNewline() @@ -619,7 +634,7 @@ func (m *chatModel) toggleInlineToolOutput() { m.toolOutputOpen = !m.toolOutputOpen m.applyToolOutputMode() m.selection = chatSelection{} - m.scroll = clamp(m.scroll, 0, m.maxScroll()) + m.scroll = 0 } func (m chatModel) updateCtrlC() (tea.Model, tea.Cmd) { @@ -710,6 +725,9 @@ func (m chatModel) updateUpKey() (tea.Model, tea.Cmd) { if m.moveInputCursorVertical(-1) { return m, nil } + if slices.Contains(m.input, '\n') { + return m, nil + } m.movePromptHistory(-1) return m, nil } @@ -751,6 +769,9 @@ func (m chatModel) updateDownKey() (tea.Model, tea.Cmd) { if m.moveInputCursorVertical(1) { return m, nil } + if slices.Contains(m.input, '\n') { + return m, nil + } m.movePromptHistory(1) return m, nil } @@ -816,11 +837,11 @@ func (m chatModel) View() string { func (m chatModel) flowView(width int) string { allTranscriptLines := m.transcriptLines(width) - bottomLines := m.bottomLines(width, 0) - bottomGap := transcriptInputGap(0, len(bottomLines), len(allTranscriptLines)) - printed := clamp(m.flowPrintedLines, 0, len(allTranscriptLines)) lines := slices.Clone(allTranscriptLines[printed:]) + + bottomLines := m.bottomLines(width, 0) + bottomGap := transcriptInputGap(0, len(bottomLines), len(allTranscriptLines)) for range bottomGap { lines = append(lines, "") } @@ -842,6 +863,25 @@ func (m chatModel) withFlowTranscriptRepaint(cmd tea.Cmd) (tea.Model, tea.Cmd) { return next, tea.Sequence(tea.ClearScreen, printCmd, cmd) } +func (m chatModel) withFlowTranscriptReplace(cmd tea.Cmd) (tea.Model, tea.Cmd) { + if m.boundedFrame { + return m.withFlowTranscriptFlush(cmd) + } + m.flowPrintedLines = 0 + next, printCmd := m.flowTranscriptFlushCmd() + return next, tea.Sequence(clearScreenAndScrollbackCmd(), printCmd, cmd) +} + +func clearScreenAndScrollbackCmd() tea.Cmd { + return func() tea.Msg { + // CSI 3J clears scrollback; 2J + H clear and home the visible screen. + // This is only used when flow-mode transcript history is intentionally + // replaced from retained state, such as ctrl+o detail toggling. + fmt.Print("\x1b[3J\x1b[2J\x1b[H") + return nil + } +} + func (m chatModel) flowTranscriptFlushCmd() (chatModel, tea.Cmd) { if m.boundedFrame || m.resumePicker != nil || m.modelPicker != nil || m.thinkPicker != nil || m.historyPopup != nil { return m, nil @@ -854,6 +894,9 @@ func (m chatModel) flowTranscriptFlushCmd() (chatModel, tea.Cmd) { } m.flowPrintedLines = clamp(m.flowPrintedLines, 0, len(lines)) flushCount := m.flowTranscriptFlushCount(lines, width) + if m.flowPrintedLines > flushCount { + m.flowPrintedLines = flushCount + } if flushCount <= m.flowPrintedLines { return m, nil } @@ -887,6 +930,10 @@ func (m chatModel) flowTranscriptHoldEntryIndex() int { if entry.content != "" { return index } + case "thinking": + if entry.status == "running" { + return index + } case "tool": if isToolActiveStatus(entry.status) { return index diff --git a/cmd/tui/chat/events.go b/cmd/tui/chat/events.go index 6d086ffa2..0b1db5c8a 100644 --- a/cmd/tui/chat/events.go +++ b/cmd/tui/chat/events.go @@ -40,6 +40,7 @@ type chatCompactProgressMsg struct { // resetStreamingState clears the transient streaming flags that every // non-streaming event resets before applying its own state. func (m *chatModel) resetStreamingState() { + m.finishThinkingEntry() m.awaitingModel = false m.thinking = false m.thinkingTokens = 0 @@ -48,6 +49,7 @@ func (m *chatModel) resetStreamingState() { // resetRunState clears all run-progress flags (streaming plus compaction // progress) for terminal events that fully reset the run view. func (m *chatModel) resetRunState() { + m.finishThinkingEntry() m.awaitingModel = false m.compacting = false m.compactingTokens = 0 @@ -89,6 +91,7 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) { } idx := m.ensureLiveAssistantMessage() m.liveMessages[idx].Thinking += event.Thinking + m.syncThinkingEntry() contextChanged = true } case coreagent.EventMessageDelta: @@ -101,6 +104,7 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) { m.liveMessages[msgIdx].Content += event.Content contextChanged = true case coreagent.EventToolCallDetected: + m.finishThinkingEntry() m.awaitingModel = m.running m.thinking = false m.thinkingTokens = 0 @@ -160,7 +164,6 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) { contextChanged = true case coreagent.EventToolsUnavailable: m.resetStreamingState() - m.entries = append(m.entries, newChatEntry(chatEntry{role: "system", content: "Tools are unavailable for this model."})) case coreagent.EventCompacted: m.resetRunState() skipResponseMetrics = true @@ -194,6 +197,7 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) { m.entries = append(m.entries, newChatEntry(chatEntry{role: "system", content: message})) m.status = "compact skipped" case coreagent.EventModelStreamDone: + m.finishThinkingEntry() m.awaitingModel = m.running && m.awaitingToolStart() case coreagent.EventError: m.resetRunState() diff --git a/cmd/tui/chat/events_test.go b/cmd/tui/chat/events_test.go index 04d5b4b0e..8a72875dc 100644 --- a/cmd/tui/chat/events_test.go +++ b/cmd/tui/chat/events_test.go @@ -343,6 +343,9 @@ func TestChatModelPreloadUnsupportedThinkingDisablesThinkingAndRetries(t *testin if len(fm.entries) != 0 { t.Fatalf("unsupported thinking should not render a preload error: %#v", fm.entries) } + if strings.Contains(strings.ToLower(fm.status), "thinking") { + t.Fatalf("unsupported thinking should not render a status notice: %q", fm.status) + } msg := cmd() batch, ok := msg.(tea.BatchMsg) @@ -357,6 +360,19 @@ func TestChatModelPreloadUnsupportedThinkingDisablesThinkingAndRetries(t *testin } } +func TestChatToolsUnavailableIsSilent(t *testing.T) { + m := chatModel{running: true} + + m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolsUnavailable}) + + if len(m.entries) != 0 { + t.Fatalf("tools unavailable should not render a transcript entry: %#v", m.entries) + } + if strings.Contains(stripANSI(m.View()), "Tools are unavailable") { + t.Fatalf("tools unavailable notice should be omitted: %q", stripANSI(m.View())) + } +} + func TestChatModelPreloadIgnoresStaleCompletion(t *testing.T) { m := chatModel{preloadingModel: "qwen3"} @@ -391,6 +407,23 @@ func TestChatThinkingShowsTokenCount(t *testing.T) { } } +func TestChatCtrlOShowsThinkingDetails(t *testing.T) { + m := chatModel{running: true, boundedFrame: true, width: 100, height: 20} + + m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "Need to inspect cwd."}) + + if transcript := stripANSI(m.renderTranscript(80)); strings.Contains(transcript, "Need to inspect cwd.") { + t.Fatalf("thinking details should be hidden before ctrl+o: %q", transcript) + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + m = updated.(chatModel) + transcript := stripANSI(m.renderTranscript(80)) + if !strings.Contains(transcript, "Thinking") || !strings.Contains(transcript, "Need to inspect cwd.") { + t.Fatalf("ctrl+o should reveal thinking details: %q", transcript) + } +} + func TestChatModelLineShowsContextOnlyWhenUseful(t *testing.T) { m := chatModel{ width: 120, @@ -414,7 +447,7 @@ func TestChatModelLineShowsContextOnlyWhenUseful(t *testing.T) { m.contextTokens = 61 view = stripANSI(m.View()) - if !strings.Contains(view, "llama3.2 · ctx ~61 / 100 (61% used)") || strings.Contains(view, "compaction soon") { + if !strings.Contains(view, "llama3.2 ctx ~61 / 100 (61% used)") || strings.Contains(view, "compaction soon") { t.Fatalf("view should show context count beside model after 60%% without compaction copy: %q", view) } if count := strings.Count(view, "ctx "); count != 1 { @@ -423,7 +456,7 @@ func TestChatModelLineShowsContextOnlyWhenUseful(t *testing.T) { m.contextTokens = 65 view = stripANSI(m.View()) - if !strings.Contains(view, "llama3.2 · ctx ~65 / 100 (65% used)") || strings.Contains(view, "compaction soon") { + if !strings.Contains(view, "llama3.2 ctx ~65 / 100 (65% used)") || strings.Contains(view, "compaction soon") { t.Fatalf("view should show context count near threshold without compaction copy: %q", view) } if count := strings.Count(view, "ctx "); count != 1 { @@ -432,7 +465,7 @@ func TestChatModelLineShowsContextOnlyWhenUseful(t *testing.T) { m.contextTokens = 75 view = stripANSI(m.View()) - if !strings.Contains(view, "llama3.2 · ctx ~75 / 100 (75% used)") || strings.Contains(view, "compaction soon") { + if !strings.Contains(view, "llama3.2 ctx ~75 / 100 (75% used)") || strings.Contains(view, "compaction soon") { t.Fatalf("view should show context count at threshold without compaction copy: %q", view) } @@ -1480,10 +1513,13 @@ func TestChatAgentEventsUpdateAssistantEntry(t *testing.T) { m.applyAgentEvent(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "thinking"}) m.applyAgentEvent(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "done"}) - if len(m.entries) != 1 { - t.Fatalf("entries = %d, want 1", len(m.entries)) + if len(m.entries) != 2 { + t.Fatalf("entries = %d, want hidden thinking plus assistant", len(m.entries)) } - entry := m.entries[0] + if m.entries[0].role != "thinking" || m.entries[0].content != "thinking" || m.entries[0].expanded { + t.Fatalf("thinking entry = %#v, want retained but hidden thinking", m.entries[0]) + } + entry := m.entries[1] if entry.role != "assistant" { t.Fatalf("role = %q, want assistant", entry.role) } diff --git a/cmd/tui/chat/input.go b/cmd/tui/chat/input.go index 6918b7b8e..da6b1ef26 100644 --- a/cmd/tui/chat/input.go +++ b/cmd/tui/chat/input.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "unicode" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -40,6 +41,8 @@ type chatCompletion struct { const ( chatPromptPrefix = "" inputBoxHorizontalPadding = 1 + inputCursorGlyph = "█" + inputCursorMarker = "\x00" maxInputBoxBodyLines = 12 ) @@ -401,6 +404,7 @@ func pastedTextLineCount(input string) int { } func (m *chatModel) insertInputRunes(runes []rune) { + runes = normalizeInputRunes(runes) if len(runes) == 0 { return } @@ -417,6 +421,30 @@ func (m *chatModel) insertInputRunes(runes []rune) { m.complete = 0 } +func normalizeInputRunes(runes []rune) []rune { + for _, r := range runes { + if r == '\r' { + return normalizeInputRunesSlow(runes) + } + } + return runes +} + +func normalizeInputRunesSlow(runes []rune) []rune { + out := make([]rune, 0, len(runes)) + for i := 0; i < len(runes); i++ { + if runes[i] != '\r' { + out = append(out, runes[i]) + continue + } + out = append(out, '\n') + if i+1 < len(runes) && runes[i+1] == '\n' { + i++ + } + } + return out +} + func (m *chatModel) deleteInputBackward() { cursor := m.normalizedInputCursor() if cursor <= 0 { @@ -743,6 +771,22 @@ func (m *chatModel) moveInputCursorWord(delta int) bool { return true } +func (m *chatModel) handleInputAltRunes(runes []rune) bool { + if len(runes) != 1 { + return false + } + switch runes[0] { + case 'b', 'B': + m.moveInputCursorWord(-1) + return true + case 'f', 'F': + m.moveInputCursorWord(1) + return true + default: + return false + } +} + func (m *chatModel) moveInputCursorVertical(delta int) bool { if delta == 0 || len(m.input) == 0 { return false @@ -796,7 +840,7 @@ func inputWithCursor(input []rune, cursor int) string { cursor = clamp(cursor, 0, len(input)) next := make([]rune, 0, len(input)+1) next = append(next, input[:cursor]...) - next = append(next, '█') + next = append(next, []rune(inputCursorMarker)...) next = append(next, input[cursor:]...) return string(next) } @@ -840,7 +884,7 @@ func renderInputBoxLines(input string, cursor int, width, maxBodyLines int, plac var raw []string placeholderMode := input == "" && strings.TrimSpace(placeholder) != "" if input == "" && strings.TrimSpace(placeholder) != "" { - raw = renderInputPromptRawLines(inputWithCursor([]rune(" "+placeholder), 0), prefix, continuationPrefix, bodyWidth) + raw = renderInputPromptRawLines(inputWithCursor([]rune(placeholder), 0), prefix, continuationPrefix, bodyWidth) } else { if cursor >= 0 { input = inputWithCursor([]rune(input), cursor) @@ -853,14 +897,14 @@ func renderInputBoxLines(input string, cursor int, width, maxBodyLines int, plac } lines := make([]string, 0, len(raw)+2) - lines = append(lines, chatMetaStyle.Render(inputBoxTopBorderLine(width))) + lines = append(lines, chatInputBorderStyle.Render(inputBoxTopBorderLine(width))) for i, line := range raw { rendered := line if placeholderMode { if i == 0 && strings.HasPrefix(line, prefix) { rest := strings.TrimPrefix(line, prefix) - if strings.HasPrefix(rest, "█") { - rendered = chatUserStyle.Render(prefix+"█") + chatMetaStyle.Render(strings.TrimPrefix(rest, "█")) + if strings.HasPrefix(rest, inputCursorMarker) { + rendered = chatUserStyle.Render(prefix) + renderInputTextWithCursorStyle(rest, chatMetaStyle) lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth)) continue } @@ -877,16 +921,52 @@ func renderInputBoxLines(input string, cursor int, width, maxBodyLines int, plac lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth)) continue } - lines = append(lines, renderInputBoxBodyLine(chatUserStyle.Render(rendered), contentWidth)) + lines = append(lines, renderInputBoxBodyLine(renderInputTextWithCursor(rendered), contentWidth)) } - lines = append(lines, chatMetaStyle.Render(inputBoxBottomBorderLine(width))) + lines = append(lines, chatInputBorderStyle.Render(inputBoxBottomBorderLine(width))) return lines } +func renderInputTextWithCursor(line string) string { + return renderInputTextWithCursorStyle(line, chatUserStyle) +} + +func renderInputTextWithCursorStyle(line string, style lipgloss.Style) string { + before, after, ok := strings.Cut(line, inputCursorMarker) + if !ok { + return style.Render(line) + } + cell, rest := inputCursorCell(after) + return style.Render(before) + renderInputCursorCell(cell) + style.Render(rest) +} + +func inputCursorCell(after string) (string, string) { + if after == "" { + return "", "" + } + r, size := utf8.DecodeRuneInString(after) + if r == '\n' { + return "", after + } + return after[:size], after[size:] +} + +func renderInputCursor() string { + return renderInputCursorCell("") +} + +func renderInputCursorCell(cell string) string { + if cell == "" { + return chatBlankCursorStyle.Render(inputCursorGlyph) + } + return chatCursorStyle.Render(cell) +} + func renderInputPromptRawLines(text, prefix, continuationPrefix string, width int) []string { if width <= 0 { width = 1 } + text = string(normalizeInputRunes([]rune(text))) body := wrapChatText(text, width) for i, line := range body { if i == 0 { @@ -930,7 +1010,7 @@ func inputBoxBorderLine(width int, left, right string) string { func renderInputBoxBodyLine(line string, width int) string { padding := strings.Repeat(" ", inputBoxHorizontalPadding) - return chatMetaStyle.Render("│") + padding + padRenderedLine(line, width) + padding + chatMetaStyle.Render("│") + return chatInputBorderStyle.Render("│") + padding + padRenderedLine(line, width) + padding + chatInputBorderStyle.Render("│") } func padRenderedLine(line string, width int) string { diff --git a/cmd/tui/chat/input_test.go b/cmd/tui/chat/input_test.go index 45247a895..fb5af8a2d 100644 --- a/cmd/tui/chat/input_test.go +++ b/cmd/tui/chat/input_test.go @@ -67,6 +67,66 @@ func TestRenderInputBoxTruncationAlignsContinuation(t *testing.T) { } } +func TestRenderInputBoxCursorUsesMutedStyle(t *testing.T) { + rendered := strings.Join(renderInputBoxLines("hello", len("hello"), 24, 1, ""), "\n") + if !strings.Contains(rendered, renderInputCursor()) { + t.Fatalf("cursor should use muted style: %q", rendered) + } +} + +func TestInputWithCursorCoversCurrentCharacter(t *testing.T) { + if got, want := inputWithCursor([]rune("hello"), 0), inputCursorMarker+"hello"; got != want { + t.Fatalf("cursor at start = %q, want marker before first character", got) + } + if got, want := inputWithCursor([]rune("hello"), len("hello")), "hello"+inputCursorMarker; got != want { + t.Fatalf("cursor at end = %q, want appended marker", got) + } + if got := stripANSI(renderInputTextWithCursor(inputWithCursor([]rune("hello"), 0))); got != "hello" { + t.Fatalf("rendered cursor at start = %q, want visible first character", got) + } +} + +func TestChatInputNormalizesCarriageReturnsFromPaste(t *testing.T) { + m := chatModel{} + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("one\r\ntwo\rthree"), Paste: true}) + m = updated.(chatModel) + + if got, want := string(m.input), "one\ntwo\nthree"; got != want { + t.Fatalf("input = %q, want normalized newlines %q", got, want) + } + if got, want := m.normalizedInputCursor(), len([]rune("one\ntwo\nthree")); got != want { + t.Fatalf("cursor = %d, want %d", got, want) + } +} + +func TestRenderInputBoxLinesNormalizesCarriageReturns(t *testing.T) { + rendered := stripANSI(strings.Join(renderInputBoxLines("alpha\rbeta", -1, 24, 4, ""), "\n")) + if strings.Contains(rendered, "\r") { + t.Fatalf("rendered input box should not contain carriage returns: %q", rendered) + } + + lines := strings.Split(rendered, "\n") + alphaLine := -1 + betaLine := -1 + for i, line := range lines { + if strings.Contains(line, "alpha") { + alphaLine = i + } + if strings.Contains(line, "beta") { + betaLine = i + } + } + if alphaLine < 0 || betaLine < 0 || alphaLine == betaLine { + t.Fatalf("carriage return should render as separate boxed lines: %q", rendered) + } + for _, i := range []int{alphaLine, betaLine} { + if !strings.HasPrefix(lines[i], "│ ") || !strings.HasSuffix(lines[i], " │") { + t.Fatalf("input line %d should stay inside box borders: %q\n%s", i, lines[i], rendered) + } + } +} + type shiftEnterCSITestMsg string func (m shiftEnterCSITestMsg) String() string { @@ -309,6 +369,106 @@ func TestChatInputArrowKeysEditMiddle(t *testing.T) { } } +func TestChatInputOptionArrowsMoveByWord(t *testing.T) { + input := []rune("alpha beta gamma") + m := chatModel{ + input: input, + inputCursor: len(input), + inputCursorSet: true, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft, Alt: true}) + m = updated.(chatModel) + if got, want := m.inputCursor, len([]rune("alpha beta ")); got != want { + t.Fatalf("cursor after option+left = %d, want %d", got, want) + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyLeft, Alt: true}) + m = updated.(chatModel) + if got, want := m.inputCursor, len([]rune("alpha ")); got != want { + t.Fatalf("cursor after second option+left = %d, want %d", got, want) + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRight, Alt: true}) + m = updated.(chatModel) + if got, want := m.inputCursor, len([]rune("alpha beta ")); got != want { + t.Fatalf("cursor after option+right = %d, want %d", got, want) + } + if got := string(m.input); got != "alpha beta gamma" { + t.Fatalf("input = %q, want unchanged", got) + } +} + +func TestChatInputMetaBFMoveByWord(t *testing.T) { + input := []rune("alpha beta gamma") + m := chatModel{ + input: input, + inputCursor: len(input), + inputCursorSet: true, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}, Alt: true}) + m = updated.(chatModel) + if got, want := m.inputCursor, len([]rune("alpha beta ")); got != want { + t.Fatalf("cursor after meta+b = %d, want %d", got, want) + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}, Alt: true}) + m = updated.(chatModel) + if got, want := m.inputCursor, len(input); got != want { + t.Fatalf("cursor after meta+f = %d, want %d", got, want) + } + if got := string(m.input); got != "alpha beta gamma" { + t.Fatalf("input = %q, want unchanged", got) + } +} + +func TestChatInputCtrlPNNavigateHistory(t *testing.T) { + m := chatModel{ + input: []rune("draft"), + promptHistory: []string{"old prompt"}, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) + m = updated.(chatModel) + if got := string(m.input); got != "old prompt" { + t.Fatalf("input after ctrl+p = %q, want history prompt", got) + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) + m = updated.(chatModel) + if got := string(m.input); got != "draft" { + t.Fatalf("input after ctrl+n = %q, want draft restored", got) + } +} + +func TestChatInputDeleteRemovesCharacterForward(t *testing.T) { + m := chatModel{ + input: []rune("hello world"), + inputCursor: len([]rune("hello ")), + inputCursorSet: true, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDelete}) + m = updated.(chatModel) + if got := string(m.input); got != "hello orld" { + t.Fatalf("input after delete = %q, want next character removed", got) + } + if got, want := m.inputCursor, len([]rune("hello ")); got != want { + t.Fatalf("cursor after delete = %d, want %d", got, want) + } +} + +func TestChatInputCtrlHDeletesBackward(t *testing.T) { + m := chatModel{input: []rune("hello")} + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + m = updated.(chatModel) + if got := string(m.input); got != "hell" { + t.Fatalf("input after ctrl+h = %q, want previous character removed", got) + } +} + func TestChatInputUpDownNavigateMultilineBeforeHistory(t *testing.T) { m := chatModel{ input: []rune("one\ntwo\nthree"), @@ -336,6 +496,38 @@ func TestChatInputUpDownNavigateMultilineBeforeHistory(t *testing.T) { } } +func TestChatInputBlankMultilineDoesNotRecallHistory(t *testing.T) { + m := chatModel{promptHistory: []string{"old prompt"}} + + for range 4 { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + m = updated.(chatModel) + } + if got, want := string(m.input), "\n\n\n\n"; got != want { + t.Fatalf("input = %q, want blank multiline draft %q", got, want) + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(chatModel) + if got, want := m.inputCursor, len([]rune("\n\n\n")); got != want { + t.Fatalf("cursor after up = %d, want previous blank line %d", got, want) + } + + for range 10 { + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(chatModel) + } + if got, want := string(m.input), "\n\n\n\n"; got != want { + t.Fatalf("up at top should keep multiline draft, got %q want %q", got, want) + } + if m.promptActive { + t.Fatal("up at top of multiline draft should not enter prompt history") + } + if m.inputCursor != 0 { + t.Fatalf("cursor = %d, want top of multiline draft", m.inputCursor) + } +} + func TestChatPromptHistoryNavigatesPreviousPrompts(t *testing.T) { m := chatModel{ input: []rune("draft"), @@ -436,7 +628,7 @@ func TestChatViewRendersSlashCommandSuggestions(t *testing.T) { if got := len(m.slashCommandLines(80)); got != maxSlashCompletions { t.Fatalf("slash suggestions = %d, want %d", got, maxSlashCompletions) } - if !strings.Contains(view, "/█") { + if !strings.Contains(view, "/"+inputCursorGlyph) { t.Fatalf("view missing slash input row: %q", view) } } diff --git a/cmd/tui/chat/modals_test.go b/cmd/tui/chat/modals_test.go index 553b2a2e0..5fcca31a2 100644 --- a/cmd/tui/chat/modals_test.go +++ b/cmd/tui/chat/modals_test.go @@ -430,7 +430,7 @@ func TestChatResumeCommandOpensPicker(t *testing.T) { if strings.Contains(view, "Search...") { t.Fatalf("resume picker should render inline without full search box: %q", view) } - if !strings.Contains(view, "█") { + if !strings.Contains(view, inputCursorGlyph) { t.Fatalf("inline resume picker should keep input box visible: %q", view) } } @@ -499,7 +499,7 @@ func TestChatModelCommandOpensPicker(t *testing.T) { if strings.Contains(view, "Search...") { t.Fatalf("model picker should render inline without full search box: %q", view) } - if !strings.Contains(view, "█") { + if !strings.Contains(view, inputCursorGlyph) { t.Fatalf("inline model picker should keep input box visible: %q", view) } } diff --git a/cmd/tui/chat/render.go b/cmd/tui/chat/render.go index 3f17adac6..48b7f5265 100644 --- a/cmd/tui/chat/render.go +++ b/cmd/tui/chat/render.go @@ -39,6 +39,8 @@ type chatEntry struct { renderLines []string } +const chatMessageIndent = " " + type chatEntryRenderKey struct { width int version int @@ -76,13 +78,15 @@ func (m chatModel) toolStartedAt(toolID string) time.Time { func entryHasExpandableOutput(entry chatEntry) bool { return (entry.role == "tool" && (len(entry.args) > 0 || strings.TrimSpace(entry.content) != "")) || (entry.role == "tool_group" && len(entry.tools) > 0) || - (entry.role == "compaction_summary" && strings.TrimSpace(entry.content) != "") + (entry.role == "compaction_summary" && strings.TrimSpace(entry.content) != "") || + (entry.role == "thinking" && strings.TrimSpace(entry.content) != "") } func entryHasToolOutputMode(entry chatEntry) bool { return (entry.role == "tool" && (isToolActiveStatus(entry.status) || isToolResultStatus(entry.status) || entry.content != "")) || (entry.role == "tool_group" && len(entry.tools) > 0) || - (entry.role == "compaction_summary" && strings.TrimSpace(entry.content) != "") + (entry.role == "compaction_summary" && strings.TrimSpace(entry.content) != "") || + (entry.role == "thinking" && strings.TrimSpace(entry.content) != "") } func (m *chatModel) applyToolOutputMode() { @@ -123,17 +127,23 @@ func (m *chatModel) ensureAssistantEntry() int { func (m chatModel) renderTranscript(width int) string { var b strings.Builder + first := true for index, entry := range m.entries { - if b.Len() > 0 { - b.WriteByte('\n') - } prefix, body := m.renderEntry(entry) prefixWidth := lipgloss.Width(prefix) continuation := "" if prefixWidth > 0 { continuation = strings.Repeat(" ", prefixWidth) } - for i, line := range m.renderEntryLinesCached(index, entry, body, width-prefixWidth) { + lines := m.renderEntryLinesCached(index, entry, body, width-prefixWidth) + if len(lines) == 0 { + continue + } + if !first { + b.WriteByte('\n') + } + first = false + for i, line := range lines { if i == 0 { b.WriteString(prefix) b.WriteString(line) @@ -328,7 +338,7 @@ func (m chatModel) renderModelStatusLines(width int) []string { return nil } indent := inputBoxTextIndent() - lines := wrapChatText(strings.Join(parts, " · "), max(20, width-lipgloss.Width(indent))) + lines := wrapChatText(strings.Join(parts, " "), max(20, width-lipgloss.Width(indent))) for i := range lines { lines[i] = renderFooterPlainLine(indent + lines[i]) } @@ -475,7 +485,9 @@ func (m chatModel) renderEntry(entry chatEntry) (string, string) { case "user": return "", entry.content case "assistant": - return chatAssistantStyle.Render("•") + " ", entry.content + return "", entry.content + case "thinking": + return chatMetaStyle.Render("•") + " ", thinkingStatusLine(entry) case "slash": return chatMetaStyle.Render("•") + " ", entry.content case "compaction_summary": @@ -502,9 +514,12 @@ func (m chatModel) renderEntryLines(entry chatEntry, body string, width int) []s } switch entry.role { case "assistant": - lines := splitRenderedBody(renderMarkdownForView(body, width)) - lines = append(lines, renderMetricsLines(entry.metrics, width)...) + innerWidth := max(1, width-lipgloss.Width(chatMessageIndent)) + lines := indentLines(splitRenderedBody(renderMarkdownForView(body, innerWidth)), chatMessageIndent) + lines = append(lines, indentLines(renderMetricsLines(entry.metrics, innerWidth), chatMessageIndent)...) return lines + case "thinking": + return renderThinkingLines(entry, width) case "system", "slash": return splitRenderedBody(renderMarkdownForView(body, width)) case "user": @@ -530,9 +545,10 @@ func renderUserMessageLines(content string, width int) []string { if width < 20 { width = 20 } - lines := wrapChatText(content, width) + innerWidth := max(1, width-lipgloss.Width(chatMessageIndent)) + lines := wrapChatText(content, innerWidth) for i, line := range lines { - lines[i] = chatUserBlockStyle.Render(padRenderedLine(line, width)) + lines[i] = chatUserBlockStyle.Render(padRenderedLine(chatMessageIndent+line, width)) } return lines } @@ -859,6 +875,16 @@ func renderCompactionSummaryLines(entry chatEntry, width int) []string { return lines } +func renderThinkingLines(entry chatEntry, width int) []string { + if !entry.expanded || strings.TrimSpace(entry.content) == "" { + return nil + } + lines := wrapChatText(thinkingStatusLine(entry), width) + lines = append(lines, "") + lines = append(lines, indentLines(splitRenderedBody(renderMarkdownForView(entry.content, width-2)), " ")...) + return lines +} + func compactionSummaryStatusLine(entry chatEntry) string { segment := toolStatusStyle(entry.status).Render(toolStatusLabel(entry)) if segment == "" { @@ -867,6 +893,61 @@ func compactionSummaryStatusLine(entry chatEntry) string { return fmt.Sprintf("Compacted summary %s", segment) } +func thinkingStatusLine(entry chatEntry) string { + if strings.TrimSpace(entry.label) != "" { + return entry.label + } + return "Thinking" +} + +func (m chatModel) thinkingLabel() string { + if m.thinkingTokens > 0 { + return "Thinking " + formatTokenCount(m.thinkingTokens) + } + return "Thinking" +} + +func (m *chatModel) syncThinkingEntry() { + if strings.TrimSpace(m.latestLiveThinking()) == "" { + return + } + idx := -1 + if len(m.entries) > 0 && m.entries[len(m.entries)-1].role == "thinking" && m.entries[len(m.entries)-1].status == "running" { + idx = len(m.entries) - 1 + } + if idx < 0 { + m.entries = append(m.entries, newChatEntry(chatEntry{role: "thinking", status: "running"})) + idx = len(m.entries) - 1 + } + m.entries[idx].content = m.latestLiveThinking() + m.entries[idx].label = m.thinkingLabel() + m.entries[idx].status = "running" + m.entries[idx].expanded = m.toolOutputMode && m.toolOutputOpen + m.markEntryDirty(idx) +} + +func (m chatModel) latestLiveThinking() string { + for i := len(m.liveMessages) - 1; i >= 0; i-- { + if m.liveMessages[i].Role == "assistant" && strings.TrimSpace(m.liveMessages[i].Thinking) != "" { + return m.liveMessages[i].Thinking + } + } + return "" +} + +func (m *chatModel) finishThinkingEntry() { + if len(m.entries) == 0 { + return + } + idx := len(m.entries) - 1 + if m.entries[idx].role != "thinking" || m.entries[idx].status != "running" { + return + } + m.entries[idx].status = "done" + m.entries[idx].label = m.thinkingLabel() + m.markEntryDirty(idx) +} + func toolGroupChildStatusLine(entry chatEntry) string { label := entry.label if label == "" { @@ -1351,14 +1432,14 @@ func inputBoxTextIndent() string { func renderFooterPlainLine(line string) string { const fullAccess = "full access" if !strings.Contains(line, fullAccess) { - return chatMetaStyle.Render(line) + return chatFooterStyle.Render(line) } var b strings.Builder for { before, after, ok := strings.Cut(line, fullAccess) if before != "" { - b.WriteString(chatMetaStyle.Render(before)) + b.WriteString(chatFooterStyle.Render(before)) } if !ok { break @@ -1826,6 +1907,14 @@ func entriesFromMessages(messages []api.Message) []chatEntry { } entries = append(entries, newChatEntry(chatEntry{role: msg.Role, content: msg.Content})) case "assistant": + if strings.TrimSpace(msg.Thinking) != "" { + entries = append(entries, newChatEntry(chatEntry{ + role: "thinking", + content: msg.Thinking, + label: "Thinking", + status: "done", + })) + } for _, call := range msg.ToolCalls { if call.ID != "" { toolCalls[call.ID] = call diff --git a/cmd/tui/chat/render_test.go b/cmd/tui/chat/render_test.go index 537cf7655..805eb89de 100644 --- a/cmd/tui/chat/render_test.go +++ b/cmd/tui/chat/render_test.go @@ -15,16 +15,20 @@ import ( "github.com/ollama/ollama/api" ) -func TestChatAssistantEntryUsesBullet(t *testing.T) { - m := chatModel{} +func TestChatAssistantEntryHasNoPrefixAndUsesInset(t *testing.T) { + m := chatModel{entries: []chatEntry{{role: "assistant", content: "hello"}}} - prefix, _ := m.renderEntry(chatEntry{role: "assistant", content: "hello"}) + prefix, _ := m.renderEntry(m.entries[0]) - if strings.Contains(prefix, "Ollama:") { - t.Fatalf("prefix should not include Ollama label: %q", prefix) + if prefix != "" { + t.Fatalf("prefix = %q, want empty", prefix) } - if !strings.Contains(prefix, "•") { - t.Fatalf("prefix = %q, want bullet", prefix) + transcript := stripANSI(m.renderTranscript(80)) + if strings.Contains(transcript, "•") { + t.Fatalf("assistant transcript should not include bullet: %q", transcript) + } + if !strings.HasPrefix(transcript, " hello") { + t.Fatalf("assistant transcript should use leading inset: %q", transcript) } } @@ -37,7 +41,13 @@ func TestChatViewRendersEmptyPromptHint(t *testing.T) { view := stripANSI(m.View()) lines := strings.Split(view, "\n") - hintLine := lineIndexContaining(lines, "█ ") + hintLine := -1 + for _, prompt := range chatEmptyPrompts { + if i := lineIndexContaining(lines, prompt); i >= 0 { + hintLine = i + break + } + } if hintLine < 0 { t.Fatalf("empty chat view missing prompt hint: %q", view) } @@ -64,6 +74,9 @@ func TestChatUserEntryHasNoLabel(t *testing.T) { if !strings.Contains(transcript, "hello") { t.Fatalf("user transcript should render as prompt row: %q", transcript) } + if !strings.HasPrefix(transcript, " hello") { + t.Fatalf("user transcript should use leading inset: %q", transcript) + } } func TestChatSystemEntryHasNoLabel(t *testing.T) { @@ -167,7 +180,7 @@ func TestChatViewRendersInputBox(t *testing.T) { if !strings.Contains(view, inputBoxTopBorderLine(40)) || !strings.Contains(view, inputBoxBottomBorderLine(40)) { t.Fatalf("prompt input should render rounded box borders: %q", view) } - if !strings.Contains(view, "hello█") { + if !strings.Contains(view, "hello"+inputCursorGlyph) { t.Fatalf("view missing prompt input row: %q", view) } } @@ -179,9 +192,14 @@ func TestChatViewRendersCursorWithEmptyPlaceholder(t *testing.T) { height: 12, } - view := stripANSI(m.View()) - if !strings.Contains(view, "█ ") { - t.Fatalf("empty placeholder should show cursor indicator: %q", view) + rawView := m.View() + hint := m.emptyChatHint() + first := string([]rune(hint)[0]) + if !strings.Contains(rawView, renderInputCursorCell(first)) { + t.Fatalf("empty placeholder should style first character as cursor: %q", rawView) + } + if view := stripANSI(rawView); !strings.Contains(view, hint) || strings.Contains(view, inputCursorGlyph) { + t.Fatalf("empty placeholder should keep first character visible: %q", view) } } @@ -196,7 +214,7 @@ func TestChatViewRendersModelUnderInputBox(t *testing.T) { } lines := strings.Split(stripANSI(m.View()), "\n") - inputLine := lineIndexContaining(lines, "hello█") + inputLine := lineIndexContaining(lines, "hello"+inputCursorGlyph) modelLine := lineIndexContaining(lines, "kimi-k2.7-code:cloud") if inputLine < 0 || modelLine < 0 { t.Fatalf("view missing input or model line:\n%s", strings.Join(lines, "\n")) @@ -220,7 +238,7 @@ func TestChatViewExpandsInputBoxForLongPrompt(t *testing.T) { if got := inputPromptLineCount(t, view); got < 2 { t.Fatalf("input body lines = %d, want wrapped prompt:\n%s", got, view) } - if !strings.Contains(view, "█") { + if !strings.Contains(view, inputCursorGlyph) { t.Fatalf("view missing cursor: %q", view) } } @@ -294,7 +312,7 @@ func TestChatViewRendersNotificationAboveInput(t *testing.T) { view := stripANSI(m.View()) lines := strings.Split(view, "\n") for i, line := range lines { - if strings.Contains(line, "hello█") { + if strings.Contains(line, "hello"+inputCursorGlyph) { if i < 2 || !strings.Contains(lines[i-2], "copied latest output") { t.Fatalf("notification should sit directly above input:\n%s", view) } @@ -344,7 +362,7 @@ func TestChatViewKeepsInputBoxWhileRunning(t *testing.T) { } view := stripANSI(m.View()) - if !strings.Contains(view, "next█") { + if !strings.Contains(view, "next"+inputCursorGlyph) { t.Fatalf("running view should keep input row: %q", view) } if strings.Contains(view, "↑/↓ scroll") || strings.Contains(view, "/new chat") || strings.Contains(view, "/clear reset") { @@ -352,7 +370,7 @@ func TestChatViewKeepsInputBoxWhileRunning(t *testing.T) { } lines := strings.Split(view, "\n") for i, line := range lines { - if strings.Contains(line, "next█") { + if strings.Contains(line, "next"+inputCursorGlyph) { if i < 2 || !strings.Contains(lines[i-2], "Thinking 42 tokens") { t.Fatalf("thinking line should sit directly above input:\n%s", view) } @@ -404,7 +422,7 @@ func TestChatViewDoesNotPadToTerminalHeight(t *testing.T) { if got := len(strings.Split(view, "\n")); got >= 12 { t.Fatalf("view height = %d, want less than terminal height:\n%s", got, stripANSI(view)) } - if !strings.Contains(stripANSI(view), "hello█") { + if !strings.Contains(stripANSI(view), "hello"+inputCursorGlyph) { t.Fatalf("view missing input row: %q", stripANSI(view)) } } @@ -712,6 +730,47 @@ func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) { } } +func TestChatFlowShowsAssistantAfterToolGroupingShrinksTranscript(t *testing.T) { + m := chatModel{ + width: 100, + height: 16, + running: true, + entries: []chatEntry{ + {role: "user", content: "inspect the repo"}, + }, + } + + m, _ = m.flowTranscriptFlushCmd() + if m.flowPrintedLines == 0 { + t.Fatal("user prompt should start as flushed transcript") + } + + for i := 1; i <= 4; i++ { + toolID := fmt.Sprintf("call-%d", i) + args := map[string]any{"command": fmt.Sprintf("echo %d", i)} + m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: toolID, ToolName: "bash", Args: args}) + m, _ = m.flowTranscriptFlushCmd() + m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolCallID: toolID, ToolName: "bash", Args: args, Content: fmt.Sprintf("out %d", i)}) + m, _ = m.flowTranscriptFlushCmd() + } + if m.flowPrintedLines < 5 { + t.Fatalf("completed tools should have flushed individually before grouping, printed=%d", m.flowPrintedLines) + } + + m.applyAgentEvent(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "First streamed answer line."}) + m, _ = m.flowTranscriptFlushCmd() + view := stripANSI(m.View()) + if !strings.Contains(view, "First streamed answer line.") { + t.Fatalf("live assistant text should remain visible after tool grouping:\n%s", view) + } + if m.entries[0].role != "user" || m.entries[1].role != "tool_group" || len(m.entries[1].tools) != 4 { + t.Fatalf("entries should retain grouped tools before assistant: %#v", m.entries) + } + if m.flowPrintedLines >= len(m.transcriptLines(m.width)) { + t.Fatalf("flowPrintedLines should leave live assistant managed, printed=%d transcript=%d", m.flowPrintedLines, len(m.transcriptLines(m.width))) + } +} + func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) { m := chatModel{ width: 80, @@ -757,9 +816,9 @@ func TestChatMouseDragSelectsTranscriptWithoutAutoCopy(t *testing.T) { } top, _ := m.transcriptLayout() - updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 0, Y: top}) + updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 2, Y: top}) m = updated.(chatModel) - updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 5, Y: top}) + updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 7, Y: top}) m = updated.(chatModel) if got := m.selectedTranscriptText(80); got != "alpha" { t.Fatalf("selected text = %q, want alpha", got) @@ -771,7 +830,7 @@ func TestChatMouseDragSelectsTranscriptWithoutAutoCopy(t *testing.T) { t.Fatal("selection should track drag before release") } - updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 5, Y: top}) + updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 7, Y: top}) m = updated.(chatModel) if cmd != nil { t.Fatal("mouse release should not auto-copy selected text") @@ -796,7 +855,7 @@ func TestChatMouseDragSelectionUsesDisplayColumns(t *testing.T) { }, } top, _ := m.transcriptLayout() - contentX := 0 + contentX := 2 updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: contentX, Y: top}) m = updated.(chatModel) @@ -843,14 +902,14 @@ func TestChatMouseDragSelectionUsesScrolledTranscriptCoordinates(t *testing.T) { m.scroll = m.maxScroll() top, _ := m.transcriptLayout() - updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 0, Y: top}) + updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 2, Y: top}) m = updated.(chatModel) - updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseMotion, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 7, Y: top}) + updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseMotion, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 9, Y: top}) m = updated.(chatModel) if got := m.selectedTranscriptText(80); got != "line-00" { t.Fatalf("selected text = %q, want line-00", got) } - updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 7, Y: top}) + updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 9, Y: top}) m = updated.(chatModel) if cmd != nil { t.Fatal("mouse release should not auto-copy selected text") @@ -1179,7 +1238,7 @@ func TestChatCtrlOTogglesInlineToolOutput(t *testing.T) { } } -func TestChatCtrlORepaintsFlushedFlowTranscript(t *testing.T) { +func TestChatCtrlORepaintsDetailsInFlowMode(t *testing.T) { m := chatModel{ width: 100, height: 20, @@ -1194,19 +1253,40 @@ func TestChatCtrlORepaintsFlushedFlowTranscript(t *testing.T) { updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) if cmd == nil { - t.Fatal("ctrl+o should repaint flushed flow transcript") + t.Fatal("ctrl+o should repaint retained flow transcript details") } if m.boundedFrame || m.fullScreen { - t.Fatal("ctrl+o repaint should stay in terminal-flow rendering") + t.Fatal("ctrl+o should stay in terminal-flow rendering") } - if !m.entries[1].expanded { - t.Fatalf("tool output should be expanded inline: %#v", m.entries[1]) + if !m.toolOutputOpen || !m.entries[1].expanded { + t.Fatalf("ctrl+o should expand retained tool details in flow mode: %#v", m.entries[1]) } if got, want := m.flowPrintedLines, len(m.transcriptLines(m.width)); got != want { t.Fatalf("flowPrintedLines = %d, want reprinted transcript line count %d", got, want) } } +func TestChatCtrlOFlowReplaceKeepsInputBarVisible(t *testing.T) { + m := chatModel{ + width: 100, + height: 20, + flowPrintedLines: 1, + status: "ready", + opts: Options{Model: "test-model"}, + entries: []chatEntry{ + {role: "user", content: "inspect this"}, + {role: "assistant", content: "Done."}, + }, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + m = updated.(chatModel) + view := stripANSI(m.View()) + if !strings.Contains(view, inputCursorGlyph) || !strings.Contains(view, "test-model") { + t.Fatalf("ctrl+o flow replacement should keep input surface visible: %q", view) + } +} + func TestChatCtrlOTogglesInlineOutputWithoutLeavingFullscreen(t *testing.T) { m := chatModel{ width: 100, @@ -1243,6 +1323,34 @@ func TestChatCtrlOTogglesInlineOutputWithoutLeavingFullscreen(t *testing.T) { } } +func TestChatCtrlOPinsTranscriptToBottom(t *testing.T) { + m := chatModel{ + width: 80, + height: 8, + boundedFrame: true, + fullScreen: true, + entries: []chatEntry{ + {role: "user", content: "inspect this"}, + {role: "assistant", content: strings.Repeat("older line\n", 10)}, + {role: "tool", detail: "bash", label: "Bash(\"pwd\")", status: "done", content: strings.Repeat("tool output\n", 10)}, + {role: "assistant", content: "latest answer"}, + }, + } + m.scroll = m.maxScroll() + if m.scroll == 0 { + t.Fatal("test setup should start scrolled away from bottom") + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + m = updated.(chatModel) + if m.scroll != 0 { + t.Fatalf("ctrl+o should pin transcript to bottom, got scroll %d", m.scroll) + } + if view := stripANSI(m.View()); !strings.Contains(view, "latest answer") { + t.Fatalf("bottom of transcript should stay visible after ctrl+o:\n%s", view) + } +} + func TestChatCtrlOCollapseKeepsInputPromptVisible(t *testing.T) { m := chatModel{ width: 80, @@ -1263,7 +1371,7 @@ func TestChatCtrlOCollapseKeepsInputPromptVisible(t *testing.T) { m = updated.(chatModel) view := stripANSI(m.View()) - if !strings.Contains(view, "█") { + if !strings.Contains(view, inputCursorGlyph) { t.Fatalf("input prompt disappeared after collapsing tool output:\n%s", view) } } @@ -1307,7 +1415,7 @@ func TestChatCtrlOShowsRunningToolOutputInline(t *testing.T) { if !strings.Contains(view, "/tmp/project") { t.Fatalf("finished tool output should be visible inline: %q", view) } - if !strings.Contains(view, "█") { + if !strings.Contains(view, inputCursorGlyph) { t.Fatalf("input prompt disappeared while tool output is expanded:\n%s", view) } if !strings.Contains(view, "test-model") { @@ -1317,7 +1425,7 @@ func TestChatCtrlOShowsRunningToolOutputInline(t *testing.T) { updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) view = stripANSI(m.View()) - if !strings.Contains(view, "█") { + if !strings.Contains(view, inputCursorGlyph) { t.Fatalf("input prompt disappeared after collapsing tool output:\n%s", view) } if !strings.Contains(view, "test-model") { diff --git a/cmd/tui/chat/theme.go b/cmd/tui/chat/theme.go index 3b3191c09..fc7794084 100644 --- a/cmd/tui/chat/theme.go +++ b/cmd/tui/chat/theme.go @@ -18,14 +18,27 @@ var ( chatMetaStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color(chatAnsiMuted)) + chatFooterStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#777777", Dark: "#9a9a9a"}) + + chatInputBorderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#8a8a8a", Dark: "#555555"}) + + chatCursorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("15")). + Background(lipgloss.Color(chatAnsiMuted)) + + chatBlankCursorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(chatAnsiMuted)) + chatNotificationStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("7")) chatUserStyle = lipgloss.NewStyle() chatUserBlockStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#111111")). - Background(lipgloss.Color("#eeeeee")) + Foreground(lipgloss.AdaptiveColor{Light: "#666666", Dark: "#b8b8b8"}). + Background(lipgloss.AdaptiveColor{Light: "#eeeeee", Dark: "#2a2a2a"}) chatAssistantStyle = lipgloss.NewStyle() @@ -72,7 +85,7 @@ var ( Foreground(lipgloss.Color(chatAnsiRed)) chatFullAccessStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#9f5f5f")) + Foreground(lipgloss.AdaptiveColor{Light: "#9f5f5f", Dark: "#b87373"}) chatCommandNameStyle = lipgloss.NewStyle()