diff --git a/internal/tui/flush.go b/internal/tui/flush.go index e47740db..8034500b 100644 --- a/internal/tui/flush.go +++ b/internal/tui/flush.go @@ -69,13 +69,6 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool { // of the unflushed tail is rendered once and queued for scrollback. It returns // the (possibly nil) print command for the queued batch. func (m model) settleTranscript() (model, tea.Cmd) { - // In alt-screen mode there is no native scrollback surface for tea.Println: - // Bubble Tea drops that output by design. Keep rows in the managed view so - // the chat behaves like a fullscreen app and cannot reveal prior shell - // history by scrolling. - if m.altScreen { - return m, nil - } // Never freeze history at the pre-WindowSizeMsg default width: the first // real WindowSizeMsg arrives before any user-visible content settles, and // flushing earlier would hard-wrap startup rows at 96 cols forever. @@ -87,10 +80,14 @@ func (m model) settleTranscript() (model, tea.Cmd) { // frontier themselves; this is a safety net). m.flushed = len(m.transcript) } - rc := buildRowContext(m.transcript) + oldFlushed := m.flushed + // An unsettled row blocks the frontier, so every cross-row dependency needed + // to decide or render the tail (call/result and prompt/decision pairs) also + // lives in the tail. Avoid rescanning the settled prefix on every tick. + rc := buildRowContext(m.transcript[m.flushed:]) width := chatWidth(m.width) batch := []string{} - previousKind, havePreviousKind := previousVisibleTranscriptKind(m.transcript, m.flushed, rc) + previousKind, havePreviousKind := m.flushedPreviousKind, m.flushedHavePreviousKind for m.flushed < len(m.transcript) { row := m.transcript[m.flushed] if !m.settledRow(row, rc) { @@ -100,6 +97,15 @@ func (m model) settleTranscript() (model, tea.Cmd) { if row.kind == rowWelcome || rc.skip(row) { continue } + // Bubble Tea intentionally discards tea.Println in alt-screen mode. The + // bookkeeping still advances; the settled prefix is retained in the body + // item cache rebuilt below. + if m.altScreen { + m.flushedAny = true + previousKind = row.kind + havePreviousKind = true + continue + } rendered := m.renderRowMode(row, width, rc, true) if rendered == "" { continue @@ -115,6 +121,15 @@ func (m model) settleTranscript() (model, tea.Cmd) { previousKind = row.kind havePreviousKind = true } + m.flushedPreviousKind = previousKind + m.flushedHavePreviousKind = havePreviousKind + if m.altScreen { + bodyWidth := m.chatColumnWidth() + if m.flushed != oldFlushed || m.altScreenSettledWidth != bodyWidth || m.altScreenSettledFrontier != m.flushed { + m.rebuildAltScreenSettledItems(bodyWidth) + } + return m, nil + } if len(batch) > 0 { m.flushQueue = append(m.flushQueue, strings.Join(batch, "\n")) } @@ -139,6 +154,11 @@ func (m model) drainFlushQueue() (model, tea.Cmd) { // surface ended. func (m *model) resetFlushFrontier(divider string) { m.flushed = 0 + m.flushedPreviousKind = rowWelcome + m.flushedHavePreviousKind = false + m.altScreenSettledItems = nil + m.altScreenSettledWidth = 0 + m.altScreenSettledFrontier = 0 // Renumbers every row's bodyY from the top (used by /clear, /resume, /rewind, // /compact); a transcript-hover target's bodyY would otherwise risk // coincidentally matching an unrelated row in the rebuilt transcript. @@ -147,3 +167,35 @@ func (m *model) resetFlushFrontier(divider string) { m.flushQueue = append(m.flushQueue, zeroTheme.faint.Render(divider)) } } + +// rebuildAltScreenSettledItems snapshots the stable prefix once when the +// frontier (or width) changes. View then reuses these item descriptors and only +// constructs descriptors/context for the live tail. +func (m *model) rebuildAltScreenSettledItems(width int) { + if !m.altScreen || m.flushed == 0 { + m.altScreenSettledItems = nil + m.altScreenSettledWidth = width + m.altScreenSettledFrontier = m.flushed + return + } + snapshot := *m + snapshot.transcript = m.transcript[:m.flushed] + snapshot.flushed = 0 + snapshot.flushedAny = false + snapshot.flushedHavePreviousKind = false + snapshot.altScreenSettledItems = nil + snapshot.altScreenSettledWidth = 0 + snapshot.altScreenSettledFrontier = 0 + snapshot.pending = false + snapshot.pendingSpecReview = nil + snapshot.fileView = fileViewState{} + built := snapshot.transcriptBodyItems(width, "", false) + // Keep scratch capacity for the live tail. transcriptBodyItems can then + // append transient rows without copying the entire settled prefix. The cache + // length never includes those scratch entries, and the next frame overwrites + // them on the single Bubble Tea render goroutine. + m.altScreenSettledItems = make([]transcriptBodyItem, len(built), len(built)+256) + copy(m.altScreenSettledItems, built) + m.altScreenSettledWidth = width + m.altScreenSettledFrontier = m.flushed +} diff --git a/internal/tui/flush_test.go b/internal/tui/flush_test.go index f43982cf..63b4b559 100644 --- a/internal/tui/flush_test.go +++ b/internal/tui/flush_test.go @@ -36,7 +36,7 @@ func TestSettledRowsAdvanceFrontierAndLeaveLiveView(t *testing.T) { } } -func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) { +func TestAltScreenAdvancesFrontierAndCachesSettledRows(t *testing.T) { m := newModel(context.Background(), Options{AltScreen: true}) updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) m = updated.(model) @@ -47,12 +47,29 @@ func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) { if cmd != nil { t.Fatal("alt-screen mode should not print rows into native scrollback") } - if next.flushed != 0 { - t.Fatalf("alt-screen mode should keep the flush frontier unchanged, got %d", next.flushed) + if next.flushed != len(next.transcript) { + t.Fatalf("alt-screen mode should advance the frontier to %d, got %d", len(next.transcript), next.flushed) } view := viewString(next.View()) if !strings.Contains(view, "hello there") || !strings.Contains(view, "noted") { - t.Fatalf("settled rows should remain in the managed alt-screen view, got %q", view) + t.Fatalf("cached settled rows should remain in the managed alt-screen view, got %q", view) + } +} + +func TestAltScreenSettledCacheRebuildsAfterRowToggle(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = updated.(model) + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowReasoning, text: "private thought"}) + m, _ = m.settleTranscript() + + m = m.toggleTranscriptRow(len(m.transcript) - 1) + m, _ = m.settleTranscript() + if !m.transcript[len(m.transcript)-1].expanded { + t.Fatal("expected reasoning row to be expanded") + } + if m.altScreenSettledWidth == 0 || len(m.altScreenSettledItems) == 0 { + t.Fatal("expected invalidated settled cache to be rebuilt") } } diff --git a/internal/tui/model.go b/internal/tui/model.go index 90877766..b4dd0d16 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -280,16 +280,23 @@ type model struct { chatBodyLines int // Flush-frontier state (see flush.go). In inline mode, transcript[:flushed] - // is already in native scrollback; in alt-screen mode this frontier stays - // idle so history cannot reveal prior shell output. + // is already in native scrollback. Alt-screen mode advances the same + // frontier, but keeps the settled prefix as cached body items so fullscreen + // scrolling still exposes the complete transcript without rebuilding it on + // every frame. // flushedAny gates the first turn-separator blank line; flushQueue/ // printInFlight serialize ordered scrollback prints; headerPrinted records // the one-time inline title-bar print at startup. - flushed int - flushedAny bool - flushQueue []string - printInFlight bool - headerPrinted bool + flushed int + flushedAny bool + flushedPreviousKind rowKind + flushedHavePreviousKind bool + flushQueue []string + printInFlight bool + headerPrinted bool + altScreenSettledItems []transcriptBodyItem + altScreenSettledWidth int + altScreenSettledFrontier int // Composer input history (shell-style ↑/↓ recall of submitted inputs). // lastPrompt is the verbatim text of the most recent submitted prompt, so @@ -369,8 +376,9 @@ type model struct { // mouseReleased, when true, forces terminal mouse capture OFF so the user can // drag-select and copy text natively (Ctrl+E toggles it). App mouse features // (clickable suggestions, right-click paste, transcript select) pause while on. - mouseReleased bool - transcriptSelection transcriptSelectionState + mouseReleased bool + transcriptSelection transcriptSelectionState + transcriptInteraction *transcriptRenderInteraction // hover identifies the single clickable row (if any) currently under the // mouse cursor with no button pressed, so it renders in a distinct style — // the visual cue that it's clickable. Requires AllMotion mouse reporting @@ -808,6 +816,7 @@ func newModel(ctx context.Context, options Options) model { usageTracker: usageTracker, transcript: initialTranscript(), transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries), + transcriptInteraction: &transcriptRenderInteraction{}, prService: prService, prState: prService.GetState(), input: input, diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index 4569a255..bdf65173 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -66,6 +66,11 @@ func rcKey(runID int, id string) string { } func buildRowContext(rows []transcriptRow) rowContext { + if len(rows) == 0 { + // Nil maps are safe for all rowContext lookups. This is the steady-state + // frontier-at-tail path, so avoid allocating maps on every View. + return rowContext{} + } rc := rowContext{ resolved: map[string]bool{}, hints: map[string]string{}, diff --git a/internal/tui/transcript_issue561_bench_test.go b/internal/tui/transcript_issue561_bench_test.go new file mode 100644 index 00000000..c5f131c0 --- /dev/null +++ b/internal/tui/transcript_issue561_bench_test.go @@ -0,0 +1,29 @@ +package tui + +import ( + "fmt" + "testing" +) + +func benchmarkIssue561Model(turns int) model { + m := transcriptViewTestModel() + m.altScreen = true + for i := 0; i < turns; i++ { + m.transcript = append(m.transcript, + transcriptRow{kind: rowUser, text: fmt.Sprintf("question %d", i)}, + transcriptRow{kind: rowAssistant, text: fmt.Sprintf("answer %d", i), final: true}, + ) + } + m, _ = m.settleTranscript() + return m +} + +func BenchmarkIssue561SettledAltScreen(b *testing.B) { + m := benchmarkIssue561Model(5000) + width := m.chatColumnWidth() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.transcriptBodyItems(width, "", false) + } +} diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index 3b89e329..b8072567 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -4,6 +4,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "unicode/utf8" @@ -38,6 +39,29 @@ type transcriptSelectionState struct { cursor transcriptSelectionPoint } +// transcriptRenderInteraction lets settled-item render closures read the +// current selection and hover state without rebuilding the cached descriptors. +// The mutex keeps the shared cache state safe even if a renderer inspects a +// model copy concurrently with an update. +type transcriptRenderInteraction struct { + mu sync.Mutex + selection transcriptSelectionState + hover hoverTarget +} + +func (s *transcriptRenderInteraction) set(selection transcriptSelectionState, hover hoverTarget) { + s.mu.Lock() + s.selection = selection + s.hover = hover + s.mu.Unlock() +} + +func (s *transcriptRenderInteraction) get() (transcriptSelectionState, hoverTarget) { + s.mu.Lock() + defer s.mu.Unlock() + return s.selection, s.hover +} + type transcriptSelectableLine struct { bodyY int rowIndex int @@ -167,6 +191,9 @@ func shiftSelectableX(lines []transcriptSelectableLine, gutter int) []transcript // so the highlight is computed in the same shifted coordinate the mouse maps to // and lands exactly where the user selected (instead of gutter cells off). func (m model) finalizeTranscriptBodyRow(rendered string, selectable []transcriptSelectableLine, gutter int, startBodyY int) transcriptBodyRenderedItem { + if m.transcriptInteraction != nil { + m.transcriptSelection, m.hover = m.transcriptInteraction.get() + } lines := padTranscriptBodyLines(viewLines(rendered), gutter) shifted := shiftSelectableX(selectable, gutter) if m.transcriptSelection.active { @@ -211,6 +238,21 @@ func (m model) renderHoverHighlight(rendered string, selectable []transcriptSele } func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool) []transcriptBodyItem { + if m.transcriptInteraction != nil { + m.transcriptInteraction.set(m.transcriptSelection, m.hover) + } + // Steady-state alt-screen frames (notably the idle cursor blink) can reuse + // the settled list directly. Keeping this fast path outside the closure-heavy + // builder also prevents the large model receiver from escaping to the heap. + if m.altScreen && !detailed && !m.fileView.active && !m.pending && m.pendingSpecReview == nil && + m.flushedAny && m.flushed == len(m.transcript) && + m.altScreenSettledWidth == width && m.altScreenSettledFrontier == m.flushed { + return m.altScreenSettledItems + } + return m.buildTranscriptBodyItems(width, emptyOverlay, detailed) +} + +func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed bool) []transcriptBodyItem { // File drill-in: the chat column's body swaps to the viewed file's // diff/content. Swapping HERE (the single source every consumer reads) keeps // the viewport, scroll engine, renderer, and mouse hit-tests consistent. @@ -239,7 +281,6 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool items = append(items, transcriptBlockBodyItem(transcriptBodyItemEmpty, -1, m.emptyState(width))) } } else { - rc := buildRowContext(m.transcript) shownAny := false // The detailed view shows the full transcript from index 0, not // the managed region after m.flushed. @@ -247,11 +288,32 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool if detailed { startIdx = 0 } + useSettledCache := m.altScreen && !detailed && + m.altScreenSettledWidth == width && + m.altScreenSettledFrontier == m.flushed + if useSettledCache { + // The cache reserves scratch capacity for live-tail descriptors, avoiding + // an O(history) slice copy on every streaming/spinner frame. + items = m.altScreenSettledItems[:len(m.altScreenSettledItems)] + } else if m.altScreen && !detailed { + // A directly-constructed model (not yet passed through Update), or an + // explicitly invalidated cache, must still render complete history. + // The next settle rebuilds the cache for subsequent frames. + startIdx = 0 + } + // Dependencies for an unflushed row cannot live wholly before the + // frontier: such a row would have blocked the frontier. Build context only + // for the live tail instead of rescanning settled history each frame. + rc := buildRowContext(m.transcript[startIdx:]) renderRowFn := transcriptRowDispatchFn(m.renderTranscriptRow) if detailed { renderRowFn = transcriptRowDispatchFn(m.renderTranscriptDetailedRow) } - previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc) + if startIdx > 0 { + previousKind, havePreviousKind = m.flushedPreviousKind, m.flushedHavePreviousKind + } else { + previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc) + } specialistSummaryEmitted := false for index := startIdx; index < len(m.transcript); index++ { row := m.transcript[index] @@ -1442,6 +1504,11 @@ func (m model) toggleTranscriptRow(rowIndex int) model { switch m.transcript[rowIndex].kind { case rowReasoning, rowToolResult: m.transcript[rowIndex].expanded = !m.transcript[rowIndex].expanded + // Settled alt-screen rows are cached across frames; force a rebuild so an + // expand/collapse interaction is reflected immediately. + if rowIndex < m.flushed { + m.altScreenSettledWidth = 0 + } } return m }