Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions internal/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading