mirror of
https://github.com/ollama/ollama.git
synced 2026-07-23 09:10:53 -05:00
more styling
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user