diff --git a/internal/tui/model.go b/internal/tui/model.go index 258b8ba..ed334c3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -52,6 +52,12 @@ type streamStageMsg struct { detail string // optional detail text, e.g. "3 documents" } +// streamContentMsg is sent from the API goroutine each time a CONTENT fragment +// arrives, allowing the TUI to display response text incrementally. +type streamContentMsg struct { + content string +} + // streamCompleteMsg carries the complete collected response from a single API call. type streamCompleteMsg struct { text string // all CONTENT message text concatenated @@ -101,10 +107,11 @@ type Model struct { slashPickerIdx int slashCandidates []slashCmd - agentMode components.AgentEnum // agent used for API calls; changed by /mode command - currentStage string // Glean thinking stage shown while streaming: "Searching", "Reading", etc. - currentDetail string // optional detail for the current stage - streamCh chan tea.Msg // channel from the API goroutine; nil when not streaming + agentMode components.AgentEnum // agent used for API calls; changed by /mode command + currentStage string // Glean thinking stage shown while streaming: "Searching", "Reading", etc. + currentDetail string // optional detail for the current stage + streamCh chan tea.Msg // channel from the API goroutine; nil when not streaming + streamedContent string // partial content accumulated from streamContentMsg during streaming // Mouse state mouseEnabled bool // when true, mouse scrolling works but text selection requires Shift+drag @@ -386,6 +393,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.isStreaming = true + m.streamedContent = "" // Transition to active state: fix viewport at max height. // This only runs once per session — after this the viewport never resizes. if !m.conversationActive { @@ -437,6 +445,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.stopwatch, spCmd = m.stopwatch.Update(msg) return m, spCmd + case streamContentMsg: + m.streamedContent += msg.content + m.viewport.SetContent(m.renderConversation()) + m.viewport.GotoBottom() + return m, listenStreamCh(m.streamCh) + case streamStageMsg: m.currentStage = msg.stage m.currentDetail = msg.detail @@ -456,6 +470,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.isStreaming = false m.currentStage = "" m.currentDetail = "" + m.streamedContent = "" m.streamCh = nil _ = m.stopwatch.Stop() if msg.chatID != nil { @@ -627,10 +642,10 @@ func (m *Model) callAPI() tea.Cmd { } } case components.MessageTypeContent: - // Collect content — displayed only once the full response is in. for _, frag := range apiMsg.Fragments { if frag.Text != nil && *frag.Text != "" { textBuf.WriteString(*frag.Text) + ch <- streamContentMsg{content: *frag.Text} } for _, sr := range frag.StructuredResults { if sr.Document == nil { @@ -879,6 +894,10 @@ func (m *Model) renderConversation() string { sb.WriteString("\n") sb.WriteString(m.spinner.View() + " " + label) sb.WriteString("\n\n") + + if m.streamedContent != "" { + sb.WriteString(m.renderMarkdown(m.streamedContent)) + } } if m.lastErr != nil { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 3fe338f..4ddcd93 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -611,3 +611,43 @@ func TestStatusBarNoMouseIndicatorWhenEnabled(t *testing.T) { status := m.statusLine() assert.NotContains(t, status, "🖱️ off", "status bar should not show indicator when mouse is enabled") } + +func TestStreamContentMsgAccumulates(t *testing.T) { + m := newTestModel(t) + m.conversationActive = true + m.isStreaming = true + m.streamCh = make(chan tea.Msg, 8) + + updated, _ := m.Update(streamContentMsg{content: "Hello "}) + r := updated.(*Model) + assert.Equal(t, "Hello ", r.streamedContent) + + updated2, _ := r.Update(streamContentMsg{content: "world!"}) + r2 := updated2.(*Model) + assert.Equal(t, "Hello world!", r2.streamedContent) + + rendered := r2.renderConversation() + assert.Contains(t, rendered, "Hello world", "partial content must appear in rendered conversation") +} + +func TestStreamedContentResetOnComplete(t *testing.T) { + m := newTestModel(t) + m.conversationActive = true + m.isStreaming = true + m.streamedContent = "partial content" + + updated, _ := m.Update(streamCompleteMsg{text: "full response", elapsed: "2s"}) + r := updated.(*Model) + assert.Equal(t, "", r.streamedContent, "streamedContent must be cleared on completion") +} + +func TestStreamedContentResetOnNewMessage(t *testing.T) { + m := newTestModel(t) + m.conversationActive = true + m.streamedContent = "leftover" + m.textarea.SetValue("new question") + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + r := updated.(*Model) + assert.Equal(t, "", r.streamedContent, "streamedContent must be cleared when starting new message") +}