Skip to content
Open
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
70 changes: 61 additions & 9 deletions internal/tui/flush.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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"))
}
Expand All @@ -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.
Expand All @@ -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
}
25 changes: 21 additions & 4 deletions internal/tui/flush_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
}

Expand Down
27 changes: 18 additions & 9 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions internal/tui/rendering.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
29 changes: 29 additions & 0 deletions internal/tui/transcript_issue561_bench_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
71 changes: 69 additions & 2 deletions internal/tui/transcript_selection.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -239,19 +281,39 @@ 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.
startIdx := m.flushed
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]
Expand Down Expand Up @@ -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
}
Expand Down
Loading