more styling

This commit is contained in:
Jeffrey Morgan
2026-06-29 11:40:25 -07:00
parent 7e25686d58
commit 64bb0f9f61
10 changed files with 651 additions and 82 deletions

View File

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

View File

@@ -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:
if msg.Alt {
m.moveInputCursorWord(-1)
} else {
m.moveInputCursorHorizontal(-1)
}
case tea.KeyRight:
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:
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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