diff --git a/tui/component_chat_stream.go b/tui/component_chat_stream.go index c5c02f44..2029965e 100644 --- a/tui/component_chat_stream.go +++ b/tui/component_chat_stream.go @@ -46,6 +46,9 @@ func (m *model) appendAssistantDelta(delta string) { delta = m.suppressedAssistantDelta + delta m.suppressedAssistantDelta = "" } + if m.deltaDuplicatesFinalAssistant(delta) { + return + } } if m.streamingIndex >= 0 && m.streamingIndex < len(m.chatItems) { current := m.chatItems[m.streamingIndex].Body @@ -132,6 +135,7 @@ func (m *model) finishAssistantMessage(content string) { } } + m.removeLatestOpenAssistantInCurrentTurn() m.chatItems = append(m.chatItems, chatEntry{ Kind: "assistant", Title: assistantLabel, @@ -140,6 +144,73 @@ func (m *model) finishAssistantMessage(content string) { }) } +func (m *model) removeLatestOpenAssistantInCurrentTurn() { + for i := len(m.chatItems) - 1; i >= 0; i-- { + item := m.chatItems[i] + if item.Kind == "user" { + return + } + if item.Kind != "assistant" { + continue + } + switch strings.TrimSpace(strings.ToLower(item.Status)) { + case "pending", "thinking", "streaming", "settling": + m.chatItems = append(m.chatItems[:i], m.chatItems[i+1:]...) + if m.streamingIndex == i { + m.streamingIndex = -1 + } else if m.streamingIndex > i { + m.streamingIndex-- + } + return + } + } +} + +func (m *model) removeDuplicateOpenAssistantPreviews() { + for i := 0; i < len(m.chatItems); i++ { + item := m.chatItems[i] + if item.Kind != "assistant" || !isOpenAssistantStatus(item.Status) { + continue + } + if normalizeAssistantFinalBody(item.Body) == "" { + continue + } + for j := i + 1; j < len(m.chatItems); j++ { + next := m.chatItems[j] + if next.Kind == "user" { + break + } + if next.Kind == "assistant" && isFinalAssistantStatus(next.Status) && sameAssistantFinalBody(item.Body, next.Body) { + m.chatItems = append(m.chatItems[:i], m.chatItems[i+1:]...) + if m.streamingIndex == i { + m.streamingIndex = -1 + } else if m.streamingIndex > i { + m.streamingIndex-- + } + i-- + break + } + } + } +} + +func (m model) deltaDuplicatesFinalAssistant(delta string) bool { + delta = normalizeAssistantFinalBody(delta) + if delta == "" { + return false + } + for i := len(m.chatItems) - 1; i >= 0; i-- { + item := m.chatItems[i] + if item.Kind == "user" { + return false + } + if item.Kind == "assistant" && isFinalAssistantStatus(item.Status) { + return normalizeAssistantFinalBody(item.Body) == delta + } + } + return false +} + func (m model) latestTailOpenAssistantIndex() int { if len(m.chatItems) == 0 { return -1 @@ -157,6 +228,19 @@ func (m model) latestTailOpenAssistantIndex() int { } } +func isOpenAssistantStatus(status string) bool { + switch strings.TrimSpace(strings.ToLower(status)) { + case "pending", "thinking", "streaming", "settling": + return true + default: + return false + } +} + +func isFinalAssistantStatus(status string) bool { + return strings.TrimSpace(strings.ToLower(status)) == "final" +} + func sameAssistantFinalBody(a, b string) bool { return normalizeAssistantFinalBody(a) == normalizeAssistantFinalBody(b) } diff --git a/tui/component_chat_stream_test.go b/tui/component_chat_stream_test.go index 74f6242f..02153e31 100644 --- a/tui/component_chat_stream_test.go +++ b/tui/component_chat_stream_test.go @@ -776,6 +776,86 @@ func TestHandleAgentEventDeltaAppendsToStream(t *testing.T) { } } +func TestFinalAssistantMessageReplacesFullStreamingDeltaAfterRunFinished(t *testing.T) { + content := strings.Join([]string{ + "你说的情况我理解了--你在 UI 里看到我先输出了一段短文本。", + "", + "1. 第一段是 generating。", + "2. 第二段是 answer。", + }, "\n") + m := model{ + async: make(chan tea.Msg, 1), + busy: true, + chatItems: []chatEntry{ + {Kind: "user", Title: "You", Body: "为什么输出两遍", Status: "final"}, + }, + streamingIndex: -1, + } + + m.handleAgentEvent(Event{Type: EventAssistantDelta, Content: content}) + got, _ := m.Update(runFinishedMsg{}) + updated := got.(model) + updated.handleAgentEvent(Event{Type: EventRunFinished, Content: content}) + updated.handleAgentEvent(Event{Type: EventAssistantMessage, Content: content}) + + assistantCount := 0 + for _, item := range updated.chatItems { + if item.Kind == "assistant" { + assistantCount++ + if item.Status != "final" { + t.Fatalf("expected only final assistant item, got %+v", item) + } + } + } + if assistantCount != 1 { + t.Fatalf("expected final message to replace streaming delta, got %d assistant items: %+v", assistantCount, updated.chatItems) + } +} + +func TestRunFinishedRemovesDuplicateOpenAssistantPreview(t *testing.T) { + m := model{ + async: make(chan tea.Msg, 1), + chatItems: []chatEntry{ + {Kind: "user", Title: "You", Body: "hello", Status: "final"}, + {Kind: "assistant", Title: assistantLabel, Body: "same answer", Status: "streaming"}, + {Kind: "assistant", Title: assistantLabel, Body: "same answer\n\nProcessed for 1s", Status: "final"}, + }, + streamingIndex: 1, + } + + got, _ := m.Update(runFinishedMsg{}) + updated := got.(model) + + if len(updated.chatItems) != 2 { + t.Fatalf("expected duplicate streaming preview to be removed, got %+v", updated.chatItems) + } + if updated.chatItems[1].Status != "final" || !strings.Contains(updated.chatItems[1].Body, "same answer") { + t.Fatalf("expected final answer to remain, got %+v", updated.chatItems[1]) + } + if updated.streamingIndex != -1 { + t.Fatalf("expected streaming index to be cleared, got %d", updated.streamingIndex) + } +} + +func TestAppendAssistantDeltaIgnoresLateDuplicateAfterFinalAnswer(t *testing.T) { + m := model{ + chatItems: []chatEntry{ + {Kind: "user", Title: "You", Body: "hello", Status: "final"}, + {Kind: "assistant", Title: assistantLabel, Body: "same answer\n\nProcessed for 1s", Status: "final"}, + }, + streamingIndex: -1, + } + + m.appendAssistantDelta("same answer") + + if len(m.chatItems) != 2 { + t.Fatalf("expected late duplicate delta to be ignored, got %+v", m.chatItems) + } + if m.streamingIndex != -1 { + t.Fatalf("expected streaming index to stay cleared, got %d", m.streamingIndex) + } +} + func TestHandleAgentEventToolCallStartedAppendsToolCard(t *testing.T) { m := model{ chatItems: []chatEntry{ diff --git a/tui/component_viewport_layout.go b/tui/component_viewport_layout.go index 6ba4f960..4d774f28 100644 --- a/tui/component_viewport_layout.go +++ b/tui/component_viewport_layout.go @@ -76,8 +76,10 @@ func (m *model) syncViewportSize() { if bodyHeight < 6 { bodyHeight = 6 } - statusHeight := lipgloss.Height(m.renderStatusBar()) - panelInnerHeight := max(4, bodyHeight-panelStyle.GetVerticalFrameSize()-statusHeight-1) + width := max(24, m.chatPanelInnerWidth()) + statusHeight := lipgloss.Height(m.renderStatusBarWithWidth(width)) + topRightHeight := m.mainPanelTopRightHeight(width) + panelInnerHeight := max(4, bodyHeight-panelStyle.GetVerticalFrameSize()-statusHeight-topRightHeight-1) m.planView.Width = 0 m.planView.Height = 0 contentHeight := max(3, panelInnerHeight) @@ -88,6 +90,18 @@ func (m *model) syncViewportSize() { m.syncCopyViewOffset() } +func (m model) mainPanelTopRightHeight(width int) int { + width = max(1, width) + height := 0 + if badge := strings.TrimSpace(m.renderTopRightCluster(width)); badge != "" { + height += lipgloss.Height(lipgloss.PlaceHorizontal(width, lipgloss.Right, badge)) + if popup := strings.TrimSpace(m.tokenUsage.PopupView()); popup != "" { + height += lipgloss.Height(lipgloss.PlaceHorizontal(width, lipgloss.Right, popup)) + } + } + return height +} + func (m *model) syncCopyViewOffset() { if m == nil { return diff --git a/tui/model.go b/tui/model.go index 3a90b739..cb6a9643 100644 --- a/tui/model.go +++ b/tui/model.go @@ -809,6 +809,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case agentEventMsg: m.handleAgentEvent(msg.Event) + m.removeDuplicateOpenAssistantPreviews() m.refreshViewport() return m, waitForAsync(m.async) case runFinishedMsg: @@ -905,6 +906,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.stalled = false m.lastTokenReceivedAt = time.Time{} + m.removeDuplicateOpenAssistantPreviews() m.refreshViewport() return m, tea.Batch(waitForAsync(m.async), m.loadSessionsCmd()) case approvalRequestMsg: diff --git a/tui/model_layout_viewport.go b/tui/model_layout_viewport.go index 13c81091..bf54ab24 100644 --- a/tui/model_layout_viewport.go +++ b/tui/model_layout_viewport.go @@ -145,12 +145,6 @@ func (m model) conversationViewportBoundsByLayout() (left, right, top, bottom in func (m model) conversationViewportOffsetInMainPanel() int { width := max(24, m.chatPanelInnerWidth()) offset := lipgloss.Height(m.renderStatusBar()) - badge := strings.TrimSpace(m.renderTopRightCluster(width)) - if badge != "" { - offset += lipgloss.Height(lipgloss.PlaceHorizontal(width, lipgloss.Right, badge)) - if popup := strings.TrimSpace(m.tokenUsage.PopupView()); popup != "" { - offset += lipgloss.Height(lipgloss.PlaceHorizontal(width, lipgloss.Right, popup)) - } - } + offset += m.mainPanelTopRightHeight(width) return offset + 1 } diff --git a/tui/model_layout_viewport_test.go b/tui/model_layout_viewport_test.go index a2d92911..92bbd37c 100644 --- a/tui/model_layout_viewport_test.go +++ b/tui/model_layout_viewport_test.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" xansi "github.com/charmbracelet/x/ansi" ) @@ -361,3 +362,41 @@ func TestConversationViewportBoundsByLayoutStartsAtPanelLeft(t *testing.T) { t.Fatalf("expected viewport width %d from bounds, got %d", m.viewport.Width, right-left+1) } } + +func TestChatViewHeightStaysWithinWindowWithTopRightClusterAfterScroll(t *testing.T) { + input := textarea.New() + input.Focus() + m := model{ + screen: screenChat, + width: 120, + height: 30, + input: input, + viewport: viewport.New(0, 0), + planView: viewport.New(0, 0), + tokenUsage: newTokenUsageComponent(), + workspace: `D:\happycoding\lzy1\bytemind`, + } + _ = m.tokenUsage.SetUsage(2345, 5000) + for i := 0; i < 24; i++ { + m.chatItems = append(m.chatItems, chatEntry{ + Kind: "assistant", + Title: assistantLabel, + Body: strings.Repeat("message ", 12), + Status: "final", + }) + } + + m.resize() + if got := lipgloss.Height(m.View()); got > m.height { + t.Fatalf("expected initial chat view height <= %d, got %d", m.height, got) + } + + got, _ := m.handleMouse(tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + }) + scrolled := got.(model) + if got := lipgloss.Height(scrolled.View()); got > scrolled.height { + t.Fatalf("expected scrolled chat view height <= %d, got %d", scrolled.height, got) + } +} diff --git a/tui/model_test.go b/tui/model_test.go index e3d56fd3..bbbf0039 100644 --- a/tui/model_test.go +++ b/tui/model_test.go @@ -7997,7 +7997,7 @@ func TestFinishAssistantMessageFinalizesStreamingCardAfterPendingPlaceholder(t * } } -func TestFinishAssistantMessageDoesNotMoveFinalAnswerBeforeToolTail(t *testing.T) { +func TestFinishAssistantMessageRemovesStaleStreamingBeforeToolTail(t *testing.T) { m := model{ chatItems: []chatEntry{ {Kind: "assistant", Title: assistantLabel, Body: "old streamed text", Status: "streaming"}, @@ -8008,8 +8008,11 @@ func TestFinishAssistantMessageDoesNotMoveFinalAnswerBeforeToolTail(t *testing.T m.finishAssistantMessage("final answer after tool") - if len(m.chatItems) != 3 { - t.Fatalf("expected final message after tool tail, got %d items", len(m.chatItems)) + if len(m.chatItems) != 2 { + t.Fatalf("expected stale streaming card to be removed before appending final answer, got %d items", len(m.chatItems)) + } + if m.chatItems[0].Kind != "tool" || m.chatItems[0].Status != "done" { + t.Fatalf("expected tool tail to remain before final answer, got %+v", m.chatItems[0]) } last := m.chatItems[len(m.chatItems)-1] if last.Kind != "assistant" || last.Status != "final" || last.Body != "final answer after tool" {