From 12a71cbd81d36d02b4d1625ff1118e4cee4ecf85 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 09:31:53 +0800 Subject: [PATCH 1/2] fix(tui): separate activity from transcript --- internal/tui/app.go | 1 + internal/tui/commands.go | 6 ++ internal/tui/update.go | 166 +++++++++++++++++++++++++++--------- internal/tui/update_test.go | 165 ++++++++++++++++++++++++++++++++--- internal/tui/view.go | 50 +++++++++++ 5 files changed, 338 insertions(+), 50 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 830a6678..08cd4bff 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -30,6 +30,7 @@ type App struct { transcript viewport.Model input textarea.Model activeMessages []provider.Message + activities []activityEntry fileCandidates []string focus panel width int diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 18dfea81..ccb805e4 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -37,6 +37,8 @@ const ( sidebarTitle = "Sessions" sidebarFilterHint = "Type / to search" sidebarOpenHint = "Enter to open" + activityTitle = "Activity" + activitySubtitle = "Latest execution events" draftSessionTitle = "Draft" emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type /help to browse local commands." @@ -61,8 +63,12 @@ const ( focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" + focusLabelActivity = "Activity" focusLabelComposer = "Composer" + activityPreviewEntries = 3 + maxActivityEntries = 64 + messageTagUser = "[ YOU ]" messageTagAgent = "[ NEO ]" messageTagTool = "[ TOOL ]" diff --git a/internal/tui/update.go b/internal/tui/update.go index 58747261..958d2f90 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" @@ -30,6 +31,7 @@ const ( composerMinHeight = 1 composerMaxHeight = 5 composerPromptWidth = 2 + mouseWheelStepLines = 3 ) func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -47,10 +49,12 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.resizeComponents() return a, tea.Batch(cmds...) case RuntimeMsg: - a.handleRuntimeEvent(typed.Event) + transcriptDirty := a.handleRuntimeEvent(typed.Event) _ = a.refreshSessions() a.syncActiveSessionTitle() - a.rebuildTranscript() + if transcriptDirty { + a.rebuildTranscript() + } cmds = append(cmds, ListenForRuntimeEvent(a.runtime.Events())) return a, tea.Batch(cmds...) case RuntimeClosedMsg: @@ -58,7 +62,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if strings.TrimSpace(a.state.StatusText) == "" { a.state.StatusText = statusRuntimeClosed } - a.rebuildTranscript() return a, tea.Batch(cmds...) case runFinishedMsg: if typed.err != nil { @@ -71,18 +74,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { a.state.ExecutionError = typed.err.Error() a.state.StatusText = typed.err.Error() - a.appendInlineMessage(roleError, typed.err.Error()) } } _ = a.refreshSessions() a.syncActiveSessionTitle() - a.rebuildTranscript() return a, tea.Batch(cmds...) case localCommandResultMsg: if typed.err != nil { a.state.ExecutionError = typed.err.Error() a.state.StatusText = typed.err.Error() - a.appendInlineMessage(roleError, typed.err.Error()) + a.appendActivity("command", "Local command failed", typed.err.Error(), true) } else { a.state.ExecutionError = "" a.state.StatusText = typed.notice @@ -91,42 +92,41 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err := a.refreshProviderPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() - a.appendInlineMessage(roleError, err.Error()) + a.appendActivity("system", "Failed to refresh providers", err.Error(), true) } if err := a.refreshModelPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() - a.appendInlineMessage(roleError, err.Error()) + a.appendActivity("system", "Failed to refresh models", err.Error(), true) } else { a.selectCurrentProvider(cfg.SelectedProvider) a.selectCurrentModel(cfg.CurrentModel) } - a.appendInlineMessage(roleSystem, typed.notice) + a.appendActivity("command", typed.notice, "", false) } - a.rebuildTranscript() - a.transcript.GotoBottom() return a, tea.Batch(cmds...) case workspaceCommandResultMsg: if typed.command == "" && typed.err != nil { a.state.ExecutionError = typed.err.Error() a.state.StatusText = typed.err.Error() - a.appendInlineMessage(roleError, typed.err.Error()) - a.rebuildTranscript() - a.transcript.GotoBottom() + a.appendActivity("command", "Workspace command failed", typed.err.Error(), true) return a, tea.Batch(cmds...) } result := formatWorkspaceCommandResult(typed.command, typed.output, typed.err) - a.activeMessages = append(a.activeMessages, provider.Message{Role: roleTool, Content: result}) if typed.err != nil { a.state.ExecutionError = typed.err.Error() a.state.StatusText = fmt.Sprintf("Command failed: %s", typed.command) + a.appendActivity("command", "Command failed", result, true) } else { a.state.ExecutionError = "" a.state.StatusText = statusCommandDone + a.appendActivity("command", "Command finished", result, false) } - a.rebuildTranscript() - a.transcript.GotoBottom() return a, tea.Batch(cmds...) + case tea.MouseMsg: + if a.handleTranscriptMouse(typed) { + return a, tea.Batch(cmds...) + } case tea.KeyMsg: if key.Matches(typed, a.keys.Quit) { return a, tea.Quit @@ -141,7 +141,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.runtime.CancelActiveRun() { a.state.StatusText = statusCanceling } - a.rebuildTranscript() return a, tea.Batch(cmds...) } if a.state.ActivePicker != pickerNone { @@ -174,11 +173,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err := a.activateSelectedSession(); err != nil { a.state.StatusText = err.Error() a.state.ExecutionError = err.Error() - a.appendInlineMessage(roleError, err.Error()) + a.appendActivity("system", "Failed to open session", err.Error(), true) } a.focus = panelInput a.applyFocus() - a.rebuildTranscript() return a, tea.Batch(cmds...) } var cmd tea.Cmd @@ -220,8 +218,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te if err := a.refreshProviderPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() - a.appendInlineMessage(roleError, err.Error()) - a.rebuildTranscript() + a.appendActivity("system", "Failed to refresh providers", err.Error(), true) return a, tea.Batch(cmds...) } a.openProviderPicker() @@ -230,8 +227,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te if err := a.refreshModelPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() - a.appendInlineMessage(roleError, err.Error()) - a.rebuildTranscript() + a.appendActivity("system", "Failed to refresh models", err.Error(), true) return a, tea.Batch(cmds...) } a.openModelPicker() @@ -248,20 +244,18 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te if err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() - a.appendInlineMessage(roleError, err.Error()) - a.rebuildTranscript() - a.transcript.GotoBottom() + a.appendActivity("command", "Invalid workspace command", err.Error(), true) return a, tea.Batch(cmds...) } + a.activities = nil a.state.StatusText = statusRunningCommand a.state.ExecutionError = "" - a.appendInlineMessage(roleEvent, "Running command: "+command) - a.rebuildTranscript() - a.transcript.GotoBottom() + a.appendActivity("command", "Running command", command, false) cmds = append(cmds, runWorkspaceCommand(a.configManager, input)) return a, tea.Batch(cmds...) } + a.activities = nil a.state.IsAgentRunning = true a.state.StreamingReply = false a.state.ExecutionError = "" @@ -354,6 +348,7 @@ func (a *App) refreshSessions() error { func (a *App) refreshMessages() error { if strings.TrimSpace(a.state.ActiveSessionID) == "" { a.activeMessages = nil + a.activities = nil return nil } @@ -363,6 +358,7 @@ func (a *App) refreshMessages() error { } a.activeMessages = session.Messages + a.activities = nil a.state.ActiveSessionTitle = session.Title return nil } @@ -407,45 +403,59 @@ func (a *App) syncConfigState(cfg config.Config) { a.state.CurrentWorkdir = cfg.Workdir } -func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { +func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { if a.state.ActiveSessionID == "" { a.state.ActiveSessionID = event.SessionID } + transcriptDirty := false + switch event.Type { case agentruntime.EventUserMessage: a.state.StatusText = statusThinking a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ExecutionError = "" + case agentruntime.EventToolCallThinking: + if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { + a.state.CurrentTool = payload + a.appendActivity("tool", "Planning tool call", payload, false) + } case agentruntime.EventToolStart: a.state.StatusText = statusRunningTool a.state.StreamingReply = false if payload, ok := event.Payload.(provider.ToolCall); ok { a.state.CurrentTool = payload.Name - a.appendInlineMessage(roleEvent, "Running tool: "+payload.Name+"...") + a.appendActivity("tool", "Running tool", payload.Name, false) } case agentruntime.EventToolResult: a.state.StreamingReply = false a.state.CurrentTool = "" if payload, ok := event.Payload.(tools.ToolResult); ok { + a.activeMessages = append(a.activeMessages, provider.Message{ + Role: roleTool, + Content: payload.Content, + IsError: payload.IsError, + }) + transcriptDirty = true if payload.IsError { a.state.ExecutionError = payload.Content a.state.StatusText = statusToolError - a.appendInlineMessage(roleError, preview(payload.Content, 88, 4)) + a.appendActivity("tool", "Tool error", preview(payload.Content, 88, 4), true) } else if strings.TrimSpace(a.state.ExecutionError) == "" { a.state.StatusText = statusToolFinished - a.appendInlineMessage(roleEvent, "Completed tool: "+payload.Name) + a.appendActivity("tool", "Completed tool", payload.Name, false) } } case agentruntime.EventAgentChunk: if payload, ok := event.Payload.(string); ok { a.appendAssistantChunk(payload) + transcriptDirty = true } case agentruntime.EventToolChunk: if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { a.state.StatusText = statusRunningTool - a.appendInlineMessage(roleEvent, preview(payload, 88, 4)) + a.appendActivity("tool", "Tool output", preview(payload, 88, 4), false) } case agentruntime.EventAgentDone: a.state.IsAgentRunning = false @@ -456,6 +466,7 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { } if payload, ok := event.Payload.(provider.Message); ok && strings.TrimSpace(payload.Content) != "" && !a.lastAssistantMatches(payload.Content) { a.activeMessages = append(a.activeMessages, provider.Message{Role: roleAssistant, Content: payload.Content}) + transcriptDirty = true } case agentruntime.EventRunCanceled: a.state.IsAgentRunning = false @@ -463,7 +474,7 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { a.state.CurrentTool = "" a.state.ExecutionError = "" a.state.StatusText = statusCanceled - a.appendInlineMessage(roleSystem, "Canceled current run.") + a.appendActivity("run", "Canceled current run", "", false) case agentruntime.EventError: a.state.StatusText = statusError a.state.IsAgentRunning = false @@ -472,9 +483,16 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { if payload, ok := event.Payload.(string); ok { a.state.ExecutionError = payload a.state.StatusText = payload - a.appendInlineMessage(roleError, payload) + a.appendActivity("run", "Runtime error", payload, true) + } + case agentruntime.EventProviderRetry: + if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { + a.state.StatusText = statusThinking + a.appendActivity("provider", "Retrying provider call", payload, false) } } + + return transcriptDirty } func (a *App) appendAssistantChunk(chunk string) { @@ -500,6 +518,29 @@ func (a *App) appendInlineMessage(role string, message string) { a.activeMessages = append(a.activeMessages, provider.Message{Role: role, Content: content}) } +func (a *App) appendActivity(kind string, title string, detail string, isError bool) { + title = strings.TrimSpace(title) + detail = strings.TrimSpace(detail) + if title == "" && detail == "" { + return + } + if title == "" { + title = detail + detail = "" + } + + a.activities = append(a.activities, activityEntry{ + Time: time.Now(), + Kind: strings.TrimSpace(kind), + Title: title, + Detail: detail, + IsError: isError, + }) + if len(a.activities) > maxActivityEntries { + a.activities = append([]activityEntry(nil), a.activities[len(a.activities)-maxActivityEntries:]...) + } +} + func (a *App) lastAssistantMatches(content string) bool { if len(a.activeMessages) == 0 { return false @@ -516,9 +557,9 @@ func (a *App) handleViewportKeys(vp *viewport.Model, msg tea.KeyMsg) { case key.Matches(msg, a.keys.ScrollDown): vp.LineDown(2) case key.Matches(msg, a.keys.PageUp): - vp.HalfViewUp() + vp.ViewUp() case key.Matches(msg, a.keys.PageDown): - vp.HalfViewDown() + vp.ViewDown() case key.Matches(msg, a.keys.Top): vp.GotoTop() case key.Matches(msg, a.keys.Bottom): @@ -526,6 +567,49 @@ func (a *App) handleViewportKeys(vp *viewport.Model, msg tea.KeyMsg) { } } +func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { + if !a.isMouseWithinTranscript(msg) { + return false + } + + switch msg.Button { + case tea.MouseButtonWheelUp: + a.transcript.LineUp(mouseWheelStepLines) + return true + case tea.MouseButtonWheelDown: + a.transcript.LineDown(mouseWheelStepLines) + return true + default: + return false + } +} + +func (a App) isMouseWithinTranscript(msg tea.MouseMsg) bool { + x, y, width, height := a.transcriptBounds() + if width <= 0 || height <= 0 { + return false + } + return msg.X >= x && msg.X < x+width && msg.Y >= y && msg.Y < y+height +} + +func (a App) transcriptBounds() (int, int, int, int) { + lay := a.computeLayout() + contentX := a.styles.doc.GetPaddingLeft() + contentY := a.styles.doc.GetPaddingTop() + headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + bodyY := contentY + headerHeight + + streamX := contentX + streamY := bodyY + if lay.stacked { + streamY += lay.sidebarHeight + } else { + streamX += lay.sidebarWidth + lay.bodyGap + } + + return streamX, streamY, lay.rightWidth, a.transcript.Height +} + func (a *App) focusNext() { order := []panel{panelSessions, panelTranscript, panelInput} current := 0 @@ -586,11 +670,12 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { sidebarBodyHeight := max(4, lay.sidebarHeight-sidebarFrameHeight-lipgloss.Height(a.renderSidebarHeader(sidebarBodyWidth))) a.sessions.SetSize(sidebarBodyWidth, sidebarBodyHeight) menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) + activityHeight := a.activityPreviewHeight() a.transcript.Width = max(24, lay.rightWidth) a.input.SetWidth(a.composerInnerWidth(lay.rightWidth)) a.input.SetHeight(a.composerHeight()) promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) - a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) + a.transcript.Height = max(6, lay.rightHeight-activityHeight-menuHeight-promptHeight) a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { @@ -690,6 +775,7 @@ func (a *App) startDraftSession() { a.state.ActiveSessionID = "" a.state.ActiveSessionTitle = draftSessionTitle a.activeMessages = nil + a.activities = nil a.state.StatusText = statusDraft a.state.ExecutionError = "" a.state.CurrentTool = "" diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index f9d7e061..cbe1dfe8 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -242,6 +242,12 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { if app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { t.Fatalf("expected canceled status without error, got %+v", app.state) } + if len(app.activeMessages) != 0 { + t.Fatalf("expected cancel notice to stay out of transcript, got %+v", app.activeMessages) + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Canceled current run" { + t.Fatalf("expected cancel notice in activity, got %+v", app.activities) + } }, }, { @@ -266,6 +272,12 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { if app.state.StatusText != statusToolError { t.Fatalf("expected status tool error, got %q", app.state.StatusText) } + if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleTool { + t.Fatalf("expected tool result to stay in transcript, got %+v", app.activeMessages) + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Tool error" { + t.Fatalf("expected tool error in activity, got %+v", app.activities) + } }, }, } @@ -1083,6 +1095,12 @@ func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { if app.state.CurrentTool != "filesystem_edit" || app.state.StatusText != statusRunningTool { t.Fatalf("unexpected tool start state: %+v", app.state) } + if len(app.activeMessages) != 0 { + t.Fatalf("expected tool start to stay out of transcript, got %+v", app.activeMessages) + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Running tool" { + t.Fatalf("expected tool start in activity, got %+v", app.activities) + } }, }, { @@ -1102,6 +1120,12 @@ func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { if app.state.CurrentTool != "" || app.state.StatusText != statusToolFinished { t.Fatalf("unexpected tool success state: %+v", app.state) } + if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleTool { + t.Fatalf("expected tool result message in transcript, got %+v", app.activeMessages) + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Completed tool" { + t.Fatalf("expected tool completion in activity, got %+v", app.activities) + } }, }, { @@ -1116,6 +1140,46 @@ func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { if app.state.ExecutionError != "boom" || app.state.IsAgentRunning { t.Fatalf("unexpected error state: %+v", app.state) } + if len(app.activeMessages) != 0 { + t.Fatalf("expected runtime error to stay out of transcript, got %+v", app.activeMessages) + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Runtime error" { + t.Fatalf("expected runtime error in activity, got %+v", app.activities) + } + }, + }, + { + name: "tool call thinking is tracked as activity", + event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventToolCallThinking, + SessionID: "s1", + Payload: "filesystem_edit", + }, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.CurrentTool != "filesystem_edit" { + t.Fatalf("expected current tool to be populated, got %+v", app.state) + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Planning tool call" { + t.Fatalf("expected planning activity, got %+v", app.activities) + } + }, + }, + { + name: "provider retry is tracked as activity", + event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventProviderRetry, + SessionID: "s1", + Payload: "retrying provider call (attempt 1/2, wait=1.0s)...", + }, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.StatusText != statusThinking { + t.Fatalf("expected provider retry to preserve thinking status, got %+v", app.state) + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Retrying provider call" { + t.Fatalf("expected provider retry activity, got %+v", app.activities) + } }, }, { @@ -1252,8 +1316,11 @@ func TestAdditionalRenderingAndToolChunkBranches(t *testing.T) { if app.state.StatusText != statusRunningTool { t.Fatalf("expected tool chunk to keep running status, got %q", app.state.StatusText) } - if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "chunk output") { - t.Fatalf("expected tool chunk preview to append inline message") + if len(app.activeMessages) != 0 { + t.Fatalf("expected tool chunk to stay out of transcript, got %+v", app.activeMessages) + } + if len(app.activities) == 0 || !strings.Contains(app.activities[len(app.activities)-1].Detail, "chunk output") { + t.Fatalf("expected tool chunk preview in activity, got %+v", app.activities) } if got := wrapCodeBlock("a\tb", 3); !strings.Contains(got, "\n") { @@ -1269,6 +1336,81 @@ func TestAdditionalRenderingAndToolChunkBranches(t *testing.T) { } } +func TestHandleViewportKeysPageScrollingUsesFullPage(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.transcript.SetContent(strings.Repeat("line\n", 120)) + app.transcript.Height = 10 + app.transcript.GotoTop() + + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyPgDown}) + if app.transcript.YOffset != 10 { + t.Fatalf("expected page down to move a full page, got offset %d", app.transcript.YOffset) + } + + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyPgUp}) + if app.transcript.YOffset != 0 { + t.Fatalf("expected page up to return a full page, got offset %d", app.transcript.YOffset) + } +} + +func TestTranscriptMouseWheelScrollsOnlyInsideTranscript(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.width = 128 + app.height = 40 + app.resizeComponents() + app.transcript.SetContent(strings.Repeat("line\n", 160)) + app.transcript.GotoTop() + + x, y, _, _ := app.transcriptBounds() + model, cmd := app.Update(tea.MouseMsg{ + X: x + 1, + Y: y + 1, + Button: tea.MouseButtonWheelDown, + Type: tea.MouseWheelDown, + }) + app = model.(App) + _ = collectTeaMessages(cmd) + if app.transcript.YOffset != mouseWheelStepLines { + t.Fatalf("expected wheel down to scroll transcript by %d lines, got %d", mouseWheelStepLines, app.transcript.YOffset) + } + + model, cmd = app.Update(tea.MouseMsg{ + X: x + 1, + Y: y + 1, + Button: tea.MouseButtonWheelUp, + Type: tea.MouseWheelUp, + }) + app = model.(App) + _ = collectTeaMessages(cmd) + if app.transcript.YOffset != 0 { + t.Fatalf("expected wheel up to scroll transcript back to top, got %d", app.transcript.YOffset) + } + + model, cmd = app.Update(tea.MouseMsg{ + X: 0, + Y: 0, + Button: tea.MouseButtonWheelDown, + Type: tea.MouseWheelDown, + }) + app = model.(App) + _ = collectTeaMessages(cmd) + if app.transcript.YOffset != 0 { + t.Fatalf("expected wheel event outside transcript to be ignored, got %d", app.transcript.YOffset) + } +} + func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { previousExecutor := workspaceCommandExecutor t.Cleanup(func() { workspaceCommandExecutor = previousExecutor }) @@ -1299,16 +1441,19 @@ func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { if app.state.StatusText != statusCommandDone { t.Fatalf("expected command done status, got %q", app.state.StatusText) } - if len(app.activeMessages) < 2 { - t.Fatalf("expected running event and command result, got %+v", app.activeMessages) + if len(app.activeMessages) != 0 { + t.Fatalf("expected workspace command flow to stay out of transcript, got %+v", app.activeMessages) + } + if len(app.activities) < 2 { + t.Fatalf("expected running event and command result in activity, got %+v", app.activities) } - first := app.activeMessages[0] - if first.Role != roleEvent || !strings.Contains(first.Content, "Running command: git status") { - t.Fatalf("expected running command notice, got %+v", first) + first := app.activities[0] + if first.Title != "Running command" || !strings.Contains(first.Detail, "git status") { + t.Fatalf("expected running command activity, got %+v", first) } - last := app.activeMessages[len(app.activeMessages)-1] - if last.Role != roleTool || !strings.Contains(last.Content, "Command: & git status") || !strings.Contains(last.Content, "stubbed output for git status") { - t.Fatalf("expected tool transcript entry with command output, got %+v", last) + last := app.activities[len(app.activities)-1] + if last.Title != "Command finished" || !strings.Contains(last.Detail, "Command: & git status") || !strings.Contains(last.Detail, "stubbed output for git status") { + t.Fatalf("expected command output in activity, got %+v", last) } app.fileCandidates = []string{"README.md", "internal/tui/update.go", "internal/tui/view.go"} diff --git a/internal/tui/view.go b/internal/tui/view.go index e848fa8f..6b31ae7a 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -130,6 +130,9 @@ func (a App) renderWaterfall(width int, height int) string { transcript := a.styles.streamContent.Width(width).Height(a.transcript.Height).Render(a.transcript.View()) parts := []string{transcript} + if activity := a.renderActivityPreview(width); activity != "" { + parts = append(parts, activity) + } if menu := a.renderCommandMenu(width); menu != "" { parts = append(parts, menu) } @@ -418,11 +421,58 @@ func (a App) focusLabel() string { return focusLabelSessions case panelTranscript: return focusLabelTranscript + case panelActivity: + return focusLabelActivity default: return focusLabelComposer } } +func (a App) activityPreviewHeight() int { + if len(a.activities) == 0 { + return 0 + } + return 6 +} + +func (a App) renderActivityPreview(width int) string { + if len(a.activities) == 0 { + return "" + } + + entries := a.activities + if len(entries) > activityPreviewEntries { + entries = entries[len(entries)-activityPreviewEntries:] + } + + lines := make([]string, 0, len(entries)) + bodyWidth := max(10, width-4) + for _, entry := range entries { + lines = append(lines, a.renderActivityLine(entry, bodyWidth)) + } + + return a.renderPanel( + activityTitle, + activitySubtitle, + strings.Join(lines, "\n"), + width, + a.activityPreviewHeight(), + a.focus == panelActivity, + ) +} + +func (a App) renderActivityLine(entry activityEntry, width int) string { + timeLabel := entry.Time.Format("15:04:05") + kindLabel := strings.ToUpper(fallback(strings.TrimSpace(entry.Kind), "event")) + + text := entry.Title + if strings.TrimSpace(entry.Detail) != "" { + text = text + ": " + entry.Detail + } + + return trimMiddle(timeLabel+" "+kindLabel+" "+strings.Join(strings.Fields(text), " "), max(12, width)) +} + func (a App) computeLayout() layout { contentWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) headerHeight := lipgloss.Height(a.renderHeader(contentWidth)) From 2e02fda46f3813220b18078bc5f8d91829a4cbc0 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 2 Apr 2026 09:45:17 +0800 Subject: [PATCH 2/2] test(tui): cover activity preview and mouse scroll --- internal/tui/update_test.go | 85 +++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index cbe1dfe8..40207d75 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -1357,6 +1357,16 @@ func TestHandleViewportKeysPageScrollingUsesFullPage(t *testing.T) { if app.transcript.YOffset != 0 { t.Fatalf("expected page up to return a full page, got offset %d", app.transcript.YOffset) } + + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyEnd}) + if !app.transcript.AtBottom() { + t.Fatalf("expected end to jump to bottom") + } + + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyHome}) + if !app.transcript.AtTop() { + t.Fatalf("expected home to jump to top") + } } func TestTranscriptMouseWheelScrollsOnlyInsideTranscript(t *testing.T) { @@ -1409,6 +1419,81 @@ func TestTranscriptMouseWheelScrollsOnlyInsideTranscript(t *testing.T) { if app.transcript.YOffset != 0 { t.Fatalf("expected wheel event outside transcript to be ignored, got %d", app.transcript.YOffset) } + + app.transcript.Height = 0 + if app.isMouseWithinTranscript(tea.MouseMsg{X: x + 1, Y: y + 1}) { + t.Fatalf("expected zero-height transcript bounds to reject mouse hits") + } + + app.transcript.Height = 10 + if app.handleTranscriptMouse(tea.MouseMsg{X: x + 1, Y: y + 1, Button: tea.MouseButtonLeft}) { + t.Fatalf("expected non-wheel mouse button to be ignored") + } + + app.width = 100 + app.height = 32 + app.resizeComponents() + stackX, stackY, _, stackH := app.transcriptBounds() + if stackH <= 0 || !app.isMouseWithinTranscript(tea.MouseMsg{X: stackX + 1, Y: stackY + 1}) { + t.Fatalf("expected stacked layout transcript bounds to accept mouse hits") + } +} + +func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + if app.activityPreviewHeight() != 0 || app.renderActivityPreview(80) != "" { + t.Fatalf("expected empty activity state to render nothing") + } + + fixed := time.Date(2026, 4, 2, 9, 30, 0, 0, time.UTC) + app.activities = []activityEntry{ + {Time: fixed, Kind: "tool", Title: "first", Detail: "alpha"}, + {Time: fixed, Kind: "", Title: "second", Detail: ""}, + {Time: fixed, Kind: "provider", Title: "third", Detail: "retry"}, + {Time: fixed, Kind: "run", Title: "fourth", Detail: "done"}, + } + app.focus = panelActivity + if app.focusLabel() != focusLabelActivity { + t.Fatalf("expected activity focus label, got %q", app.focusLabel()) + } + if app.activityPreviewHeight() != 6 { + t.Fatalf("expected fixed activity preview height, got %d", app.activityPreviewHeight()) + } + + preview := app.renderActivityPreview(64) + if !strings.Contains(preview, "second") || !strings.Contains(preview, "third") || !strings.Contains(preview, "fourth") { + t.Fatalf("expected last activity entries in preview, got %q", preview) + } + if strings.Contains(preview, "first") { + t.Fatalf("expected oldest activity entry to be trimmed from preview, got %q", preview) + } + + line := app.renderActivityLine(activityEntry{Time: fixed, Kind: "", Title: "single line", Detail: ""}, 80) + if !strings.Contains(line, "EVENT") || strings.Contains(line, "single line:") { + t.Fatalf("expected fallback kind without detail suffix, got %q", line) + } + + rendered := app.renderMessageContent("before\n```go\nfmt.Println(1)\n```\nafter", 30, app.styles.messageBody) + if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(1)") || !strings.Contains(rendered, "after") { + t.Fatalf("expected mixed prose and code to render, got %q", rendered) + } + + if got := compactStatusText("\n hello world \n", 0); got != "hello world" { + t.Fatalf("expected compact status without truncation, got %q", got) + } + if got := compactStatusText("\n \n", 10); got != "" { + t.Fatalf("expected empty compact status for blank input, got %q", got) + } + + if app.statusBadge("failed request") == "" || app.statusBadge("canceled") == "" || app.statusBadge("ready") == "" { + t.Fatalf("expected status badge branches to render non-empty output") + } } func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) {