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
84 changes: 84 additions & 0 deletions tui/component_chat_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -132,6 +135,7 @@ func (m *model) finishAssistantMessage(content string) {
}
}

m.removeLatestOpenAssistantInCurrentTurn()
m.chatItems = append(m.chatItems, chatEntry{
Kind: "assistant",
Title: assistantLabel,
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
80 changes: 80 additions & 0 deletions tui/component_chat_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
18 changes: 16 additions & 2 deletions tui/component_viewport_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 1 addition & 7 deletions tui/model_layout_viewport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
39 changes: 39 additions & 0 deletions tui/model_layout_viewport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
9 changes: 6 additions & 3 deletions tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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" {
Expand Down
Loading