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, "›") {
|
!strings.Contains(view, "›") {
|
||||||
t.Fatalf("approval view missing content: %q", 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)
|
t.Fatalf("approval view should hide the input cursor: %q", view)
|
||||||
}
|
}
|
||||||
if strings.Contains(view, "1/2/3 choose • enter select • esc deny") {
|
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 msg.err != nil {
|
||||||
if isUnsupportedThinkingError(msg.err) && thinkRequestsThinking(m.opts.Think) {
|
if isUnsupportedThinkingError(msg.err) && thinkRequestsThinking(m.opts.Think) {
|
||||||
m.opts.Think = &api.ThinkValue{Value: false}
|
m.opts.Think = &api.ThinkValue{Value: false}
|
||||||
m.status = fmt.Sprintf("Thinking disabled for %s", msg.model)
|
|
||||||
if msg.model != "" && m.opts.PreloadModel != nil {
|
if msg.model != "" && m.opts.PreloadModel != nil {
|
||||||
m.preloadingModel = msg.model
|
m.preloadingModel = msg.model
|
||||||
return m, tea.Batch(preloadModelCmd(m.ctx, m.opts.PreloadModel, msg.model, m.opts.Think), m.scheduleTick())
|
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:
|
case chatRunDoneMsg:
|
||||||
wasCanceling := m.status == "canceling"
|
wasCanceling := m.status == "canceling"
|
||||||
|
m.finishThinkingEntry()
|
||||||
m.running = false
|
m.running = false
|
||||||
m.awaitingModel = false
|
m.awaitingModel = false
|
||||||
m.compacting = 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) {
|
func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
if msg.Type == tea.KeyCtrlO {
|
if msg.Type == tea.KeyCtrlO {
|
||||||
m.toggleInlineToolOutput()
|
|
||||||
m.disarmQuit()
|
m.disarmQuit()
|
||||||
// Toggling tool output changes the transcript height; force a full
|
m.toggleInlineToolOutput()
|
||||||
// repaint so the input box and model-status footer aren't left stale.
|
|
||||||
if m.boundedFrame {
|
if m.boundedFrame {
|
||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
}
|
}
|
||||||
return m.withFlowTranscriptRepaint(nil)
|
return m.withFlowTranscriptReplace(nil)
|
||||||
}
|
}
|
||||||
if msg.Type == tea.KeyShiftTab {
|
if msg.Type == tea.KeyShiftTab {
|
||||||
return m.togglePermissionMode()
|
return m.togglePermissionMode()
|
||||||
@@ -552,14 +550,26 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyCtrlG:
|
case tea.KeyCtrlG:
|
||||||
return m.openInputEditor()
|
return m.openInputEditor()
|
||||||
|
case tea.KeyCtrlP:
|
||||||
|
return m.updateUpKey()
|
||||||
|
case tea.KeyCtrlN:
|
||||||
|
return m.updateDownKey()
|
||||||
case tea.KeyUp:
|
case tea.KeyUp:
|
||||||
return m.updateUpKey()
|
return m.updateUpKey()
|
||||||
case tea.KeyDown:
|
case tea.KeyDown:
|
||||||
return m.updateDownKey()
|
return m.updateDownKey()
|
||||||
case tea.KeyLeft:
|
case tea.KeyLeft:
|
||||||
m.moveInputCursorHorizontal(-1)
|
if msg.Alt {
|
||||||
|
m.moveInputCursorWord(-1)
|
||||||
|
} else {
|
||||||
|
m.moveInputCursorHorizontal(-1)
|
||||||
|
}
|
||||||
case tea.KeyRight:
|
case tea.KeyRight:
|
||||||
m.moveInputCursorHorizontal(1)
|
if msg.Alt {
|
||||||
|
m.moveInputCursorWord(1)
|
||||||
|
} else {
|
||||||
|
m.moveInputCursorHorizontal(1)
|
||||||
|
}
|
||||||
case tea.KeyCtrlA:
|
case tea.KeyCtrlA:
|
||||||
m.moveInputCursorToLineStart()
|
m.moveInputCursorToLineStart()
|
||||||
case tea.KeyCtrlE:
|
case tea.KeyCtrlE:
|
||||||
@@ -584,7 +594,7 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.scroll = m.maxScroll()
|
m.scroll = m.maxScroll()
|
||||||
case tea.KeyCtrlEnd:
|
case tea.KeyCtrlEnd:
|
||||||
m.scroll = 0
|
m.scroll = 0
|
||||||
case tea.KeyBackspace:
|
case tea.KeyBackspace, tea.KeyCtrlH:
|
||||||
m.resetPromptHistoryCursor()
|
m.resetPromptHistoryCursor()
|
||||||
if msg.Alt {
|
if msg.Alt {
|
||||||
m.deleteInputWordBackward()
|
m.deleteInputWordBackward()
|
||||||
@@ -600,12 +610,17 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.KeyCtrlK:
|
case tea.KeyCtrlK:
|
||||||
m.resetPromptHistoryCursor()
|
m.resetPromptHistoryCursor()
|
||||||
m.deleteInputForward()
|
m.deleteInputForward()
|
||||||
|
case tea.KeyDelete:
|
||||||
|
m.resetPromptHistoryCursor()
|
||||||
|
m.deleteInputForward()
|
||||||
case tea.KeyTab:
|
case tea.KeyTab:
|
||||||
m.applyCompletion()
|
m.applyCompletion()
|
||||||
case tea.KeySpace:
|
case tea.KeySpace:
|
||||||
m.insertInputRunes([]rune{' '})
|
m.insertInputRunes([]rune{' '})
|
||||||
case tea.KeyRunes:
|
case tea.KeyRunes:
|
||||||
m.insertInputRunesFromKey(msg.Runes, msg.Paste)
|
if !msg.Alt || !m.handleInputAltRunes(msg.Runes) {
|
||||||
|
m.insertInputRunesFromKey(msg.Runes, msg.Paste)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if m.canEditInput() && isShiftEnterCSI(msg) {
|
if m.canEditInput() && isShiftEnterCSI(msg) {
|
||||||
m.insertInputNewline()
|
m.insertInputNewline()
|
||||||
@@ -619,7 +634,7 @@ func (m *chatModel) toggleInlineToolOutput() {
|
|||||||
m.toolOutputOpen = !m.toolOutputOpen
|
m.toolOutputOpen = !m.toolOutputOpen
|
||||||
m.applyToolOutputMode()
|
m.applyToolOutputMode()
|
||||||
m.selection = chatSelection{}
|
m.selection = chatSelection{}
|
||||||
m.scroll = clamp(m.scroll, 0, m.maxScroll())
|
m.scroll = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m chatModel) updateCtrlC() (tea.Model, tea.Cmd) {
|
func (m chatModel) updateCtrlC() (tea.Model, tea.Cmd) {
|
||||||
@@ -710,6 +725,9 @@ func (m chatModel) updateUpKey() (tea.Model, tea.Cmd) {
|
|||||||
if m.moveInputCursorVertical(-1) {
|
if m.moveInputCursorVertical(-1) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if slices.Contains(m.input, '\n') {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.movePromptHistory(-1)
|
m.movePromptHistory(-1)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -751,6 +769,9 @@ func (m chatModel) updateDownKey() (tea.Model, tea.Cmd) {
|
|||||||
if m.moveInputCursorVertical(1) {
|
if m.moveInputCursorVertical(1) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if slices.Contains(m.input, '\n') {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.movePromptHistory(1)
|
m.movePromptHistory(1)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -816,11 +837,11 @@ func (m chatModel) View() string {
|
|||||||
|
|
||||||
func (m chatModel) flowView(width int) string {
|
func (m chatModel) flowView(width int) string {
|
||||||
allTranscriptLines := m.transcriptLines(width)
|
allTranscriptLines := m.transcriptLines(width)
|
||||||
bottomLines := m.bottomLines(width, 0)
|
|
||||||
bottomGap := transcriptInputGap(0, len(bottomLines), len(allTranscriptLines))
|
|
||||||
|
|
||||||
printed := clamp(m.flowPrintedLines, 0, len(allTranscriptLines))
|
printed := clamp(m.flowPrintedLines, 0, len(allTranscriptLines))
|
||||||
lines := slices.Clone(allTranscriptLines[printed:])
|
lines := slices.Clone(allTranscriptLines[printed:])
|
||||||
|
|
||||||
|
bottomLines := m.bottomLines(width, 0)
|
||||||
|
bottomGap := transcriptInputGap(0, len(bottomLines), len(allTranscriptLines))
|
||||||
for range bottomGap {
|
for range bottomGap {
|
||||||
lines = append(lines, "")
|
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)
|
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) {
|
func (m chatModel) flowTranscriptFlushCmd() (chatModel, tea.Cmd) {
|
||||||
if m.boundedFrame || m.resumePicker != nil || m.modelPicker != nil || m.thinkPicker != nil || m.historyPopup != nil {
|
if m.boundedFrame || m.resumePicker != nil || m.modelPicker != nil || m.thinkPicker != nil || m.historyPopup != nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -854,6 +894,9 @@ func (m chatModel) flowTranscriptFlushCmd() (chatModel, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.flowPrintedLines = clamp(m.flowPrintedLines, 0, len(lines))
|
m.flowPrintedLines = clamp(m.flowPrintedLines, 0, len(lines))
|
||||||
flushCount := m.flowTranscriptFlushCount(lines, width)
|
flushCount := m.flowTranscriptFlushCount(lines, width)
|
||||||
|
if m.flowPrintedLines > flushCount {
|
||||||
|
m.flowPrintedLines = flushCount
|
||||||
|
}
|
||||||
if flushCount <= m.flowPrintedLines {
|
if flushCount <= m.flowPrintedLines {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -887,6 +930,10 @@ func (m chatModel) flowTranscriptHoldEntryIndex() int {
|
|||||||
if entry.content != "" {
|
if entry.content != "" {
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
case "thinking":
|
||||||
|
if entry.status == "running" {
|
||||||
|
return index
|
||||||
|
}
|
||||||
case "tool":
|
case "tool":
|
||||||
if isToolActiveStatus(entry.status) {
|
if isToolActiveStatus(entry.status) {
|
||||||
return index
|
return index
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type chatCompactProgressMsg struct {
|
|||||||
// resetStreamingState clears the transient streaming flags that every
|
// resetStreamingState clears the transient streaming flags that every
|
||||||
// non-streaming event resets before applying its own state.
|
// non-streaming event resets before applying its own state.
|
||||||
func (m *chatModel) resetStreamingState() {
|
func (m *chatModel) resetStreamingState() {
|
||||||
|
m.finishThinkingEntry()
|
||||||
m.awaitingModel = false
|
m.awaitingModel = false
|
||||||
m.thinking = false
|
m.thinking = false
|
||||||
m.thinkingTokens = 0
|
m.thinkingTokens = 0
|
||||||
@@ -48,6 +49,7 @@ func (m *chatModel) resetStreamingState() {
|
|||||||
// resetRunState clears all run-progress flags (streaming plus compaction
|
// resetRunState clears all run-progress flags (streaming plus compaction
|
||||||
// progress) for terminal events that fully reset the run view.
|
// progress) for terminal events that fully reset the run view.
|
||||||
func (m *chatModel) resetRunState() {
|
func (m *chatModel) resetRunState() {
|
||||||
|
m.finishThinkingEntry()
|
||||||
m.awaitingModel = false
|
m.awaitingModel = false
|
||||||
m.compacting = false
|
m.compacting = false
|
||||||
m.compactingTokens = 0
|
m.compactingTokens = 0
|
||||||
@@ -89,6 +91,7 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
|
|||||||
}
|
}
|
||||||
idx := m.ensureLiveAssistantMessage()
|
idx := m.ensureLiveAssistantMessage()
|
||||||
m.liveMessages[idx].Thinking += event.Thinking
|
m.liveMessages[idx].Thinking += event.Thinking
|
||||||
|
m.syncThinkingEntry()
|
||||||
contextChanged = true
|
contextChanged = true
|
||||||
}
|
}
|
||||||
case coreagent.EventMessageDelta:
|
case coreagent.EventMessageDelta:
|
||||||
@@ -101,6 +104,7 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
|
|||||||
m.liveMessages[msgIdx].Content += event.Content
|
m.liveMessages[msgIdx].Content += event.Content
|
||||||
contextChanged = true
|
contextChanged = true
|
||||||
case coreagent.EventToolCallDetected:
|
case coreagent.EventToolCallDetected:
|
||||||
|
m.finishThinkingEntry()
|
||||||
m.awaitingModel = m.running
|
m.awaitingModel = m.running
|
||||||
m.thinking = false
|
m.thinking = false
|
||||||
m.thinkingTokens = 0
|
m.thinkingTokens = 0
|
||||||
@@ -160,7 +164,6 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
|
|||||||
contextChanged = true
|
contextChanged = true
|
||||||
case coreagent.EventToolsUnavailable:
|
case coreagent.EventToolsUnavailable:
|
||||||
m.resetStreamingState()
|
m.resetStreamingState()
|
||||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "system", content: "Tools are unavailable for this model."}))
|
|
||||||
case coreagent.EventCompacted:
|
case coreagent.EventCompacted:
|
||||||
m.resetRunState()
|
m.resetRunState()
|
||||||
skipResponseMetrics = true
|
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.entries = append(m.entries, newChatEntry(chatEntry{role: "system", content: message}))
|
||||||
m.status = "compact skipped"
|
m.status = "compact skipped"
|
||||||
case coreagent.EventModelStreamDone:
|
case coreagent.EventModelStreamDone:
|
||||||
|
m.finishThinkingEntry()
|
||||||
m.awaitingModel = m.running && m.awaitingToolStart()
|
m.awaitingModel = m.running && m.awaitingToolStart()
|
||||||
case coreagent.EventError:
|
case coreagent.EventError:
|
||||||
m.resetRunState()
|
m.resetRunState()
|
||||||
|
|||||||
@@ -343,6 +343,9 @@ func TestChatModelPreloadUnsupportedThinkingDisablesThinkingAndRetries(t *testin
|
|||||||
if len(fm.entries) != 0 {
|
if len(fm.entries) != 0 {
|
||||||
t.Fatalf("unsupported thinking should not render a preload error: %#v", fm.entries)
|
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()
|
msg := cmd()
|
||||||
batch, ok := msg.(tea.BatchMsg)
|
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) {
|
func TestChatModelPreloadIgnoresStaleCompletion(t *testing.T) {
|
||||||
m := chatModel{preloadingModel: "qwen3"}
|
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) {
|
func TestChatModelLineShowsContextOnlyWhenUseful(t *testing.T) {
|
||||||
m := chatModel{
|
m := chatModel{
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -414,7 +447,7 @@ func TestChatModelLineShowsContextOnlyWhenUseful(t *testing.T) {
|
|||||||
|
|
||||||
m.contextTokens = 61
|
m.contextTokens = 61
|
||||||
view = stripANSI(m.View())
|
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)
|
t.Fatalf("view should show context count beside model after 60%% without compaction copy: %q", view)
|
||||||
}
|
}
|
||||||
if count := strings.Count(view, "ctx "); count != 1 {
|
if count := strings.Count(view, "ctx "); count != 1 {
|
||||||
@@ -423,7 +456,7 @@ func TestChatModelLineShowsContextOnlyWhenUseful(t *testing.T) {
|
|||||||
|
|
||||||
m.contextTokens = 65
|
m.contextTokens = 65
|
||||||
view = stripANSI(m.View())
|
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)
|
t.Fatalf("view should show context count near threshold without compaction copy: %q", view)
|
||||||
}
|
}
|
||||||
if count := strings.Count(view, "ctx "); count != 1 {
|
if count := strings.Count(view, "ctx "); count != 1 {
|
||||||
@@ -432,7 +465,7 @@ func TestChatModelLineShowsContextOnlyWhenUseful(t *testing.T) {
|
|||||||
|
|
||||||
m.contextTokens = 75
|
m.contextTokens = 75
|
||||||
view = stripANSI(m.View())
|
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)
|
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.EventThinkingDelta, Thinking: "thinking"})
|
||||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "done"})
|
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "done"})
|
||||||
|
|
||||||
if len(m.entries) != 1 {
|
if len(m.entries) != 2 {
|
||||||
t.Fatalf("entries = %d, want 1", len(m.entries))
|
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" {
|
if entry.role != "assistant" {
|
||||||
t.Fatalf("role = %q, want assistant", entry.role)
|
t.Fatalf("role = %q, want assistant", entry.role)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -40,6 +41,8 @@ type chatCompletion struct {
|
|||||||
const (
|
const (
|
||||||
chatPromptPrefix = ""
|
chatPromptPrefix = ""
|
||||||
inputBoxHorizontalPadding = 1
|
inputBoxHorizontalPadding = 1
|
||||||
|
inputCursorGlyph = "█"
|
||||||
|
inputCursorMarker = "\x00"
|
||||||
maxInputBoxBodyLines = 12
|
maxInputBoxBodyLines = 12
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -401,6 +404,7 @@ func pastedTextLineCount(input string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *chatModel) insertInputRunes(runes []rune) {
|
func (m *chatModel) insertInputRunes(runes []rune) {
|
||||||
|
runes = normalizeInputRunes(runes)
|
||||||
if len(runes) == 0 {
|
if len(runes) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -417,6 +421,30 @@ func (m *chatModel) insertInputRunes(runes []rune) {
|
|||||||
m.complete = 0
|
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() {
|
func (m *chatModel) deleteInputBackward() {
|
||||||
cursor := m.normalizedInputCursor()
|
cursor := m.normalizedInputCursor()
|
||||||
if cursor <= 0 {
|
if cursor <= 0 {
|
||||||
@@ -743,6 +771,22 @@ func (m *chatModel) moveInputCursorWord(delta int) bool {
|
|||||||
return true
|
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 {
|
func (m *chatModel) moveInputCursorVertical(delta int) bool {
|
||||||
if delta == 0 || len(m.input) == 0 {
|
if delta == 0 || len(m.input) == 0 {
|
||||||
return false
|
return false
|
||||||
@@ -796,7 +840,7 @@ func inputWithCursor(input []rune, cursor int) string {
|
|||||||
cursor = clamp(cursor, 0, len(input))
|
cursor = clamp(cursor, 0, len(input))
|
||||||
next := make([]rune, 0, len(input)+1)
|
next := make([]rune, 0, len(input)+1)
|
||||||
next = append(next, input[:cursor]...)
|
next = append(next, input[:cursor]...)
|
||||||
next = append(next, '█')
|
next = append(next, []rune(inputCursorMarker)...)
|
||||||
next = append(next, input[cursor:]...)
|
next = append(next, input[cursor:]...)
|
||||||
return string(next)
|
return string(next)
|
||||||
}
|
}
|
||||||
@@ -840,7 +884,7 @@ func renderInputBoxLines(input string, cursor int, width, maxBodyLines int, plac
|
|||||||
var raw []string
|
var raw []string
|
||||||
placeholderMode := input == "" && strings.TrimSpace(placeholder) != ""
|
placeholderMode := input == "" && strings.TrimSpace(placeholder) != ""
|
||||||
if 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 {
|
} else {
|
||||||
if cursor >= 0 {
|
if cursor >= 0 {
|
||||||
input = inputWithCursor([]rune(input), cursor)
|
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 := 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 {
|
for i, line := range raw {
|
||||||
rendered := line
|
rendered := line
|
||||||
if placeholderMode {
|
if placeholderMode {
|
||||||
if i == 0 && strings.HasPrefix(line, prefix) {
|
if i == 0 && strings.HasPrefix(line, prefix) {
|
||||||
rest := strings.TrimPrefix(line, prefix)
|
rest := strings.TrimPrefix(line, prefix)
|
||||||
if strings.HasPrefix(rest, "█") {
|
if strings.HasPrefix(rest, inputCursorMarker) {
|
||||||
rendered = chatUserStyle.Render(prefix+"█") + chatMetaStyle.Render(strings.TrimPrefix(rest, "█"))
|
rendered = chatUserStyle.Render(prefix) + renderInputTextWithCursorStyle(rest, chatMetaStyle)
|
||||||
lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth))
|
lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -877,16 +921,52 @@ func renderInputBoxLines(input string, cursor int, width, maxBodyLines int, plac
|
|||||||
lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth))
|
lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth))
|
||||||
continue
|
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
|
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 {
|
func renderInputPromptRawLines(text, prefix, continuationPrefix string, width int) []string {
|
||||||
if width <= 0 {
|
if width <= 0 {
|
||||||
width = 1
|
width = 1
|
||||||
}
|
}
|
||||||
|
text = string(normalizeInputRunes([]rune(text)))
|
||||||
body := wrapChatText(text, width)
|
body := wrapChatText(text, width)
|
||||||
for i, line := range body {
|
for i, line := range body {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
@@ -930,7 +1010,7 @@ func inputBoxBorderLine(width int, left, right string) string {
|
|||||||
|
|
||||||
func renderInputBoxBodyLine(line string, width int) string {
|
func renderInputBoxBodyLine(line string, width int) string {
|
||||||
padding := strings.Repeat(" ", inputBoxHorizontalPadding)
|
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 {
|
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
|
type shiftEnterCSITestMsg string
|
||||||
|
|
||||||
func (m shiftEnterCSITestMsg) String() 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) {
|
func TestChatInputUpDownNavigateMultilineBeforeHistory(t *testing.T) {
|
||||||
m := chatModel{
|
m := chatModel{
|
||||||
input: []rune("one\ntwo\nthree"),
|
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) {
|
func TestChatPromptHistoryNavigatesPreviousPrompts(t *testing.T) {
|
||||||
m := chatModel{
|
m := chatModel{
|
||||||
input: []rune("draft"),
|
input: []rune("draft"),
|
||||||
@@ -436,7 +628,7 @@ func TestChatViewRendersSlashCommandSuggestions(t *testing.T) {
|
|||||||
if got := len(m.slashCommandLines(80)); got != maxSlashCompletions {
|
if got := len(m.slashCommandLines(80)); got != maxSlashCompletions {
|
||||||
t.Fatalf("slash suggestions = %d, want %d", 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)
|
t.Fatalf("view missing slash input row: %q", view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ func TestChatResumeCommandOpensPicker(t *testing.T) {
|
|||||||
if strings.Contains(view, "Search...") {
|
if strings.Contains(view, "Search...") {
|
||||||
t.Fatalf("resume picker should render inline without full search box: %q", view)
|
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)
|
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...") {
|
if strings.Contains(view, "Search...") {
|
||||||
t.Fatalf("model picker should render inline without full search box: %q", view)
|
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)
|
t.Fatalf("inline model picker should keep input box visible: %q", view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ type chatEntry struct {
|
|||||||
renderLines []string
|
renderLines []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chatMessageIndent = " "
|
||||||
|
|
||||||
type chatEntryRenderKey struct {
|
type chatEntryRenderKey struct {
|
||||||
width int
|
width int
|
||||||
version int
|
version int
|
||||||
@@ -76,13 +78,15 @@ func (m chatModel) toolStartedAt(toolID string) time.Time {
|
|||||||
func entryHasExpandableOutput(entry chatEntry) bool {
|
func entryHasExpandableOutput(entry chatEntry) bool {
|
||||||
return (entry.role == "tool" && (len(entry.args) > 0 || strings.TrimSpace(entry.content) != "")) ||
|
return (entry.role == "tool" && (len(entry.args) > 0 || strings.TrimSpace(entry.content) != "")) ||
|
||||||
(entry.role == "tool_group" && len(entry.tools) > 0) ||
|
(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 {
|
func entryHasToolOutputMode(entry chatEntry) bool {
|
||||||
return (entry.role == "tool" && (isToolActiveStatus(entry.status) || isToolResultStatus(entry.status) || entry.content != "")) ||
|
return (entry.role == "tool" && (isToolActiveStatus(entry.status) || isToolResultStatus(entry.status) || entry.content != "")) ||
|
||||||
(entry.role == "tool_group" && len(entry.tools) > 0) ||
|
(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() {
|
func (m *chatModel) applyToolOutputMode() {
|
||||||
@@ -123,17 +127,23 @@ func (m *chatModel) ensureAssistantEntry() int {
|
|||||||
|
|
||||||
func (m chatModel) renderTranscript(width int) string {
|
func (m chatModel) renderTranscript(width int) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
first := true
|
||||||
for index, entry := range m.entries {
|
for index, entry := range m.entries {
|
||||||
if b.Len() > 0 {
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
prefix, body := m.renderEntry(entry)
|
prefix, body := m.renderEntry(entry)
|
||||||
prefixWidth := lipgloss.Width(prefix)
|
prefixWidth := lipgloss.Width(prefix)
|
||||||
continuation := ""
|
continuation := ""
|
||||||
if prefixWidth > 0 {
|
if prefixWidth > 0 {
|
||||||
continuation = strings.Repeat(" ", prefixWidth)
|
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 {
|
if i == 0 {
|
||||||
b.WriteString(prefix)
|
b.WriteString(prefix)
|
||||||
b.WriteString(line)
|
b.WriteString(line)
|
||||||
@@ -328,7 +338,7 @@ func (m chatModel) renderModelStatusLines(width int) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
indent := inputBoxTextIndent()
|
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 {
|
for i := range lines {
|
||||||
lines[i] = renderFooterPlainLine(indent + lines[i])
|
lines[i] = renderFooterPlainLine(indent + lines[i])
|
||||||
}
|
}
|
||||||
@@ -475,7 +485,9 @@ func (m chatModel) renderEntry(entry chatEntry) (string, string) {
|
|||||||
case "user":
|
case "user":
|
||||||
return "", entry.content
|
return "", entry.content
|
||||||
case "assistant":
|
case "assistant":
|
||||||
return chatAssistantStyle.Render("•") + " ", entry.content
|
return "", entry.content
|
||||||
|
case "thinking":
|
||||||
|
return chatMetaStyle.Render("•") + " ", thinkingStatusLine(entry)
|
||||||
case "slash":
|
case "slash":
|
||||||
return chatMetaStyle.Render("•") + " ", entry.content
|
return chatMetaStyle.Render("•") + " ", entry.content
|
||||||
case "compaction_summary":
|
case "compaction_summary":
|
||||||
@@ -502,9 +514,12 @@ func (m chatModel) renderEntryLines(entry chatEntry, body string, width int) []s
|
|||||||
}
|
}
|
||||||
switch entry.role {
|
switch entry.role {
|
||||||
case "assistant":
|
case "assistant":
|
||||||
lines := splitRenderedBody(renderMarkdownForView(body, width))
|
innerWidth := max(1, width-lipgloss.Width(chatMessageIndent))
|
||||||
lines = append(lines, renderMetricsLines(entry.metrics, width)...)
|
lines := indentLines(splitRenderedBody(renderMarkdownForView(body, innerWidth)), chatMessageIndent)
|
||||||
|
lines = append(lines, indentLines(renderMetricsLines(entry.metrics, innerWidth), chatMessageIndent)...)
|
||||||
return lines
|
return lines
|
||||||
|
case "thinking":
|
||||||
|
return renderThinkingLines(entry, width)
|
||||||
case "system", "slash":
|
case "system", "slash":
|
||||||
return splitRenderedBody(renderMarkdownForView(body, width))
|
return splitRenderedBody(renderMarkdownForView(body, width))
|
||||||
case "user":
|
case "user":
|
||||||
@@ -530,9 +545,10 @@ func renderUserMessageLines(content string, width int) []string {
|
|||||||
if width < 20 {
|
if width < 20 {
|
||||||
width = 20
|
width = 20
|
||||||
}
|
}
|
||||||
lines := wrapChatText(content, width)
|
innerWidth := max(1, width-lipgloss.Width(chatMessageIndent))
|
||||||
|
lines := wrapChatText(content, innerWidth)
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
lines[i] = chatUserBlockStyle.Render(padRenderedLine(line, width))
|
lines[i] = chatUserBlockStyle.Render(padRenderedLine(chatMessageIndent+line, width))
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
@@ -859,6 +875,16 @@ func renderCompactionSummaryLines(entry chatEntry, width int) []string {
|
|||||||
return lines
|
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 {
|
func compactionSummaryStatusLine(entry chatEntry) string {
|
||||||
segment := toolStatusStyle(entry.status).Render(toolStatusLabel(entry))
|
segment := toolStatusStyle(entry.status).Render(toolStatusLabel(entry))
|
||||||
if segment == "" {
|
if segment == "" {
|
||||||
@@ -867,6 +893,61 @@ func compactionSummaryStatusLine(entry chatEntry) string {
|
|||||||
return fmt.Sprintf("Compacted summary %s", segment)
|
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 {
|
func toolGroupChildStatusLine(entry chatEntry) string {
|
||||||
label := entry.label
|
label := entry.label
|
||||||
if label == "" {
|
if label == "" {
|
||||||
@@ -1351,14 +1432,14 @@ func inputBoxTextIndent() string {
|
|||||||
func renderFooterPlainLine(line string) string {
|
func renderFooterPlainLine(line string) string {
|
||||||
const fullAccess = "full access"
|
const fullAccess = "full access"
|
||||||
if !strings.Contains(line, fullAccess) {
|
if !strings.Contains(line, fullAccess) {
|
||||||
return chatMetaStyle.Render(line)
|
return chatFooterStyle.Render(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for {
|
for {
|
||||||
before, after, ok := strings.Cut(line, fullAccess)
|
before, after, ok := strings.Cut(line, fullAccess)
|
||||||
if before != "" {
|
if before != "" {
|
||||||
b.WriteString(chatMetaStyle.Render(before))
|
b.WriteString(chatFooterStyle.Render(before))
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
@@ -1826,6 +1907,14 @@ func entriesFromMessages(messages []api.Message) []chatEntry {
|
|||||||
}
|
}
|
||||||
entries = append(entries, newChatEntry(chatEntry{role: msg.Role, content: msg.Content}))
|
entries = append(entries, newChatEntry(chatEntry{role: msg.Role, content: msg.Content}))
|
||||||
case "assistant":
|
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 {
|
for _, call := range msg.ToolCalls {
|
||||||
if call.ID != "" {
|
if call.ID != "" {
|
||||||
toolCalls[call.ID] = call
|
toolCalls[call.ID] = call
|
||||||
|
|||||||
@@ -15,16 +15,20 @@ import (
|
|||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestChatAssistantEntryUsesBullet(t *testing.T) {
|
func TestChatAssistantEntryHasNoPrefixAndUsesInset(t *testing.T) {
|
||||||
m := chatModel{}
|
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:") {
|
if prefix != "" {
|
||||||
t.Fatalf("prefix should not include Ollama label: %q", prefix)
|
t.Fatalf("prefix = %q, want empty", prefix)
|
||||||
}
|
}
|
||||||
if !strings.Contains(prefix, "•") {
|
transcript := stripANSI(m.renderTranscript(80))
|
||||||
t.Fatalf("prefix = %q, want bullet", prefix)
|
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())
|
view := stripANSI(m.View())
|
||||||
lines := strings.Split(view, "\n")
|
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 {
|
if hintLine < 0 {
|
||||||
t.Fatalf("empty chat view missing prompt hint: %q", view)
|
t.Fatalf("empty chat view missing prompt hint: %q", view)
|
||||||
}
|
}
|
||||||
@@ -64,6 +74,9 @@ func TestChatUserEntryHasNoLabel(t *testing.T) {
|
|||||||
if !strings.Contains(transcript, "hello") {
|
if !strings.Contains(transcript, "hello") {
|
||||||
t.Fatalf("user transcript should render as prompt row: %q", transcript)
|
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) {
|
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)) {
|
if !strings.Contains(view, inputBoxTopBorderLine(40)) || !strings.Contains(view, inputBoxBottomBorderLine(40)) {
|
||||||
t.Fatalf("prompt input should render rounded box borders: %q", view)
|
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)
|
t.Fatalf("view missing prompt input row: %q", view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,9 +192,14 @@ func TestChatViewRendersCursorWithEmptyPlaceholder(t *testing.T) {
|
|||||||
height: 12,
|
height: 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
view := stripANSI(m.View())
|
rawView := m.View()
|
||||||
if !strings.Contains(view, "█ ") {
|
hint := m.emptyChatHint()
|
||||||
t.Fatalf("empty placeholder should show cursor indicator: %q", view)
|
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")
|
lines := strings.Split(stripANSI(m.View()), "\n")
|
||||||
inputLine := lineIndexContaining(lines, "hello█")
|
inputLine := lineIndexContaining(lines, "hello"+inputCursorGlyph)
|
||||||
modelLine := lineIndexContaining(lines, "kimi-k2.7-code:cloud")
|
modelLine := lineIndexContaining(lines, "kimi-k2.7-code:cloud")
|
||||||
if inputLine < 0 || modelLine < 0 {
|
if inputLine < 0 || modelLine < 0 {
|
||||||
t.Fatalf("view missing input or model line:\n%s", strings.Join(lines, "\n"))
|
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 {
|
if got := inputPromptLineCount(t, view); got < 2 {
|
||||||
t.Fatalf("input body lines = %d, want wrapped prompt:\n%s", got, view)
|
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)
|
t.Fatalf("view missing cursor: %q", view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,7 +312,7 @@ func TestChatViewRendersNotificationAboveInput(t *testing.T) {
|
|||||||
view := stripANSI(m.View())
|
view := stripANSI(m.View())
|
||||||
lines := strings.Split(view, "\n")
|
lines := strings.Split(view, "\n")
|
||||||
for i, line := range lines {
|
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") {
|
if i < 2 || !strings.Contains(lines[i-2], "copied latest output") {
|
||||||
t.Fatalf("notification should sit directly above input:\n%s", view)
|
t.Fatalf("notification should sit directly above input:\n%s", view)
|
||||||
}
|
}
|
||||||
@@ -344,7 +362,7 @@ func TestChatViewKeepsInputBoxWhileRunning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
view := stripANSI(m.View())
|
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)
|
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") {
|
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")
|
lines := strings.Split(view, "\n")
|
||||||
for i, line := range lines {
|
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") {
|
if i < 2 || !strings.Contains(lines[i-2], "Thinking 42 tokens") {
|
||||||
t.Fatalf("thinking line should sit directly above input:\n%s", view)
|
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 {
|
if got := len(strings.Split(view, "\n")); got >= 12 {
|
||||||
t.Fatalf("view height = %d, want less than terminal height:\n%s", got, stripANSI(view))
|
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))
|
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) {
|
func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
|
||||||
m := chatModel{
|
m := chatModel{
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -757,9 +816,9 @@ func TestChatMouseDragSelectsTranscriptWithoutAutoCopy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
top, _ := m.transcriptLayout()
|
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)
|
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)
|
m = updated.(chatModel)
|
||||||
if got := m.selectedTranscriptText(80); got != "alpha" {
|
if got := m.selectedTranscriptText(80); got != "alpha" {
|
||||||
t.Fatalf("selected text = %q, want alpha", got)
|
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")
|
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)
|
m = updated.(chatModel)
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
t.Fatal("mouse release should not auto-copy selected text")
|
t.Fatal("mouse release should not auto-copy selected text")
|
||||||
@@ -796,7 +855,7 @@ func TestChatMouseDragSelectionUsesDisplayColumns(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
top, _ := m.transcriptLayout()
|
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})
|
updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: contentX, Y: top})
|
||||||
m = updated.(chatModel)
|
m = updated.(chatModel)
|
||||||
@@ -843,14 +902,14 @@ func TestChatMouseDragSelectionUsesScrolledTranscriptCoordinates(t *testing.T) {
|
|||||||
m.scroll = m.maxScroll()
|
m.scroll = m.maxScroll()
|
||||||
top, _ := m.transcriptLayout()
|
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)
|
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)
|
m = updated.(chatModel)
|
||||||
if got := m.selectedTranscriptText(80); got != "line-00" {
|
if got := m.selectedTranscriptText(80); got != "line-00" {
|
||||||
t.Fatalf("selected text = %q, want line-00", got)
|
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)
|
m = updated.(chatModel)
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
t.Fatal("mouse release should not auto-copy selected text")
|
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{
|
m := chatModel{
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -1194,19 +1253,40 @@ func TestChatCtrlORepaintsFlushedFlowTranscript(t *testing.T) {
|
|||||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||||
m = updated.(chatModel)
|
m = updated.(chatModel)
|
||||||
if cmd == nil {
|
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 {
|
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 {
|
if !m.toolOutputOpen || !m.entries[1].expanded {
|
||||||
t.Fatalf("tool output should be expanded inline: %#v", m.entries[1])
|
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 {
|
if got, want := m.flowPrintedLines, len(m.transcriptLines(m.width)); got != want {
|
||||||
t.Fatalf("flowPrintedLines = %d, want reprinted transcript line count %d", 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) {
|
func TestChatCtrlOTogglesInlineOutputWithoutLeavingFullscreen(t *testing.T) {
|
||||||
m := chatModel{
|
m := chatModel{
|
||||||
width: 100,
|
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) {
|
func TestChatCtrlOCollapseKeepsInputPromptVisible(t *testing.T) {
|
||||||
m := chatModel{
|
m := chatModel{
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -1263,7 +1371,7 @@ func TestChatCtrlOCollapseKeepsInputPromptVisible(t *testing.T) {
|
|||||||
m = updated.(chatModel)
|
m = updated.(chatModel)
|
||||||
|
|
||||||
view := stripANSI(m.View())
|
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)
|
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") {
|
if !strings.Contains(view, "/tmp/project") {
|
||||||
t.Fatalf("finished tool output should be visible inline: %q", view)
|
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)
|
t.Fatalf("input prompt disappeared while tool output is expanded:\n%s", view)
|
||||||
}
|
}
|
||||||
if !strings.Contains(view, "test-model") {
|
if !strings.Contains(view, "test-model") {
|
||||||
@@ -1317,7 +1425,7 @@ func TestChatCtrlOShowsRunningToolOutputInline(t *testing.T) {
|
|||||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
|
||||||
m = updated.(chatModel)
|
m = updated.(chatModel)
|
||||||
view = stripANSI(m.View())
|
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)
|
t.Fatalf("input prompt disappeared after collapsing tool output:\n%s", view)
|
||||||
}
|
}
|
||||||
if !strings.Contains(view, "test-model") {
|
if !strings.Contains(view, "test-model") {
|
||||||
|
|||||||
@@ -18,14 +18,27 @@ var (
|
|||||||
chatMetaStyle = lipgloss.NewStyle().
|
chatMetaStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(chatAnsiMuted))
|
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().
|
chatNotificationStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("7"))
|
Foreground(lipgloss.Color("7"))
|
||||||
|
|
||||||
chatUserStyle = lipgloss.NewStyle()
|
chatUserStyle = lipgloss.NewStyle()
|
||||||
|
|
||||||
chatUserBlockStyle = lipgloss.NewStyle().
|
chatUserBlockStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#111111")).
|
Foreground(lipgloss.AdaptiveColor{Light: "#666666", Dark: "#b8b8b8"}).
|
||||||
Background(lipgloss.Color("#eeeeee"))
|
Background(lipgloss.AdaptiveColor{Light: "#eeeeee", Dark: "#2a2a2a"})
|
||||||
|
|
||||||
chatAssistantStyle = lipgloss.NewStyle()
|
chatAssistantStyle = lipgloss.NewStyle()
|
||||||
|
|
||||||
@@ -72,7 +85,7 @@ var (
|
|||||||
Foreground(lipgloss.Color(chatAnsiRed))
|
Foreground(lipgloss.Color(chatAnsiRed))
|
||||||
|
|
||||||
chatFullAccessStyle = lipgloss.NewStyle().
|
chatFullAccessStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#9f5f5f"))
|
Foreground(lipgloss.AdaptiveColor{Light: "#9f5f5f", Dark: "#b87373"})
|
||||||
|
|
||||||
chatCommandNameStyle = lipgloss.NewStyle()
|
chatCommandNameStyle = lipgloss.NewStyle()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user