Read-only scout artifact for the TUI performance audit/fix goal.
Scope: Go Bubble Tea v2 TUI under tui/. No code changes in this pass.
Workspace: catalystctl/catcode · branch context at scout time: master.
| Hook | File:symbol | Role |
|---|---|---|
Init |
tui/main.go (*session).Init |
tea.Batch(startCore(), tick(), spinner.Tick) |
Update |
tui/main.go (*session).Update |
Central switch: window size, timers, core events, keys, paste |
View |
tui/render.go (*session).View → tea.View |
Always calls relayoutHeights() when ready, then chrome + viewport |
| Program | tui/main.go main |
tea.NewProgram(initialSession()) then prog.Run() |
Core I/O path:
core stdout JSONL
→ reader goroutine (startCore) → coreEvents chan (buf 256)
→ waitForEvent Cmd → coreEventMsg
→ handlers.go handleCoreEvent / switch on type
→ scheduleStreamRefresh / refresh / layout / invalidateAll
→ viewport.SetContent(renderBlocks())
→ View → cursed (incremental) renderer
Stdin path: sendCore → non-blocking stdinCh (buf 256) → writer goroutine → core stdin. Drop+log on full buffer (never block Update).
Any Update return with a model change causes View. Hot message types:
| Msg | Cadence / source | Side effect |
|---|---|---|
spinner.TickMsg |
~10 FPS while busy || !ready (spinner.Dot, time.Second/10) |
Spinner frame + View (animated border piggybacks) |
tickMsg |
tea.Tick(time.Second) via tick() — 1s, comment wrongly says 500ms |
hasLiveContent() → refresh(); toast expiry; restart spinner if needed |
streamRefreshMsg |
Coalesced 16.7ms / 33.3ms / 66.7ms by history + live-reply size | refresh() after delta/thinking batches; individual deltas reuse the last View |
tea.WindowSizeMsg |
Resize | layout() (heights + invalidate + refresh) |
coreEventMsg |
Core JSONL | handlers → layout/refresh/invalidate |
| Key / Paste / Mouse | User | input/modal; some paths refresh/invalidateAll |
updateAvailableMsg, sudoTimeoutMsg, … |
Async | occasional layout() |
Idle invariant: when !busy && ready, spinner stops (spinnerActive=false). Idle pty output must stay ~0 bytes so mouse copy works. Do not add always-on timers.
Init:tea.Batch(startCore, tick, spinner.Tick)tick()→tickMsgevery 1s (main.go~602–603)scheduleStreamRefresh(blocks.go):tea.Batch(waitForEvent, tea.Tick(frameDelay → streamRefreshMsg))withframeDelay16.7/33.3/66.7mssudo.go:tea.Tick(sudoAutoClose, …)for sudo auto-declinestartCore:tea.Tick(coreStartupTimeout, readyTimeoutMsg)
Animation policy (memory tui-animation-infrastructure): animate color only; wall-clock phase; reuse spinner cadence; CATCODE_ANIMATED_BORDER + !prefersReducedMotion() gate for comet border (renderInputBox).
Every ready frame (View):
View()
relayoutHeights() // height math — but helpers RENDER to measure
headerHeight() → renderHeader()
positionBarHeight() → renderPositionBar() (cheap: empty→0 else 1)
footerHeight()
inputBoxHeight() → renderInputBox() // may be renderInputBoxAnimated
renderFooter() → lipgloss.Height
mentionFlyoutHeight() → renderMentionFlyout()
activityShelfHeight() → renderActivityShelf()
oauthBannerHeight() // structural 0/1
goalProgressPanelHeight()→ renderGoalProgressPanel()
→ viewport.SetWidth/SetHeight, input.SetWidth
parts:
renderHeader() // AGAIN
renderCoreFailureBanner? / renderUpdateBanner? / renderOauthBanner?
viewport.View() // already-built SetContent string
renderPositionBar?
renderActivityShelf? // AGAIN if non-empty
renderGoalProgressPanel? // AGAIN
renderMentionFlyout? // AGAIN
renderInputBox() // AGAIN (animated when busy+env)
renderFooter() // AGAIN
[modal] renderModalOverlay
renderAskOverlay / renderSudoOverlay
constrainViewContent(full, W, H) // MaxWidth+MaxHeight over entire screen
tea.NewView(content) + AltScreen/MouseMode
Double-build cost: while busy (~10 FPS spinner), renderInputBox / panels used for height are built once in relayoutHeights and again for paint. Animated path rebuilds a 32-level lipgloss ramp every call (renderInputBoxAnimated).
Transcript rebuild (not every View — only on refresh/layout):
refresh()
viewport.SetContent(renderBlocks())
cache walk: for cacheIdx… finalized blocks
renderedLineOffset(cache.String()) // full string copy + Count \n
renderBlock → renderBlockFull → renderMarkdown / renderToolBlock
cache.String() copy into Builder
live cur + in-flight tools re-rendered
Streaming: delta/thinking Updates reuse the last tea.View; the coalesced refresh clock is the sole live-markdown render gate. Each scheduled render publishes all accumulated text and still parses the full live markdown (markdown.go renderMarkdown).
Tool cards: blocks.go renderToolBlock → tool_blocks.go per-name dispatch; bodies use renderOutputPanel / diff panels (truncate unless expanded).
| State | Location | Bound / notes |
|---|---|---|
blocks []*block |
session |
maxBlocks=400; trim copies into fresh slice (+ blkTrimmed marker) |
Per-block text (strings.Builder), args, output, diff, renderStr |
block |
output capped maxStoredOutput=256KiB |
cache strings.Builder + cacheIdx |
finalized transcript render | ~3× transcript size while warm; invalidateAll resets |
viewport content string |
bubbles viewport | Set by refresh |
history []string |
extras.go |
historyMax=100 |
subProgress []*subProgressEntry |
live scouts | copy-trim on remove; drives activity shelf |
todos |
latest todo_write |
pinned panel (capped display maxPinnedTodos=5) |
composerDrafts |
input ownership stack | small |
modal / ask / sudo / approval / intercom |
overlays | infrequent but heavy render when open |
coreEvents / stdinCh |
chans buf 256 | backpressure: stdin drop; stdout blocks (except final EOF line) |
invalidateAll (blocks.go): resets cache + clears every block’s renderStr / line ranges — O(n blocks).
View() tea.View(notstring); content viatea.NewView;.Contentin tests.- Keys:
tea.KeyPressMsg+msg.String(); paste:tea.PasteMsg. - Mouse:
tea.MouseMsginterface; wheel istea.MouseWheelMsg. - Alt-screen / mouse: fields on
tea.View, not program options. - Cursed renderer: view height must be ≤ terminal−1 (slack line in
relayoutHeights). Exact fill causes scroll/cursor drift. - Lipgloss v2:
Width/Heightare methods.
update.golaunchUpdateCheck: mustgo prog.Send(...)when cache hits beforeRun()— sync Send deadlocks (Bubble Tea v2).- Signal path already uses goroutine +
prog.Send(sigtermMsg{}).
- Event channel blocking send preserves events; reader drops only last line on error+EOF path.
sendCorenever blocks UI; overflow → drop + error toast.- Always re-arm
waitForEventafter handling (handlers comments ~816+) or the pump dies.
- Spinner only while
busy || !ready; idle = zero re-render. - Prefer spinner cadence over new
tea.Tickfor visuals. tickMsgcomment says 500ms but code is 1s — fix comment when touching, don’t silently change cadence without measuring copy/CPU.
- Idle spinner stop (copy storm).
relayoutHeightsinView(overflow from missed mutation sites).maxBlocks+ copy-trim (RSS prefix retention).- Stream coalescing with delta View reuse (60/30/15 FPS by history + live-reply size).
hasLiveContent-gated tick refresh.- stdin writer channel (UI freeze).
- Render-to-measure double work —
render.gorelayoutHeights+*Heighthelpers vsViewpaint. Cache structural heights or last-render heights; stop calling fullrenderInputBox/renderActivityShelf/renderGoalProgressPanel/renderMentionFlyout/renderHeadertwice per frame. layout()overuse —handlers.gohas 36s.layout()call sites (todos, scouts, banners, goal, queue, approvals, …).layout=relayoutHeights+ invalidateAll when viewport W/H changes +refresh. Panel grow/shrink changes viewport height → full transcript re-wrap. Demote torelayoutHeights()+refresh()when wrap width unchanged; keep fulllayout()for true resize / width change.tool_result→invalidateAll()—handlers.go~407: one tool finish clears entire finalized cache. Narrow to invalidate that block / fromcacheIdxof match, or re-cache only the finished tool.
renderBlocksstring copies —s.cache.String()+renderedLineOffset(s.cache.String())every extension; trackcacheLinesincrementally.- Streaming markdown O(n) per batch —
renderBlockFull→renderMarkdown(full)every ≥64 bytes → ~O(n²/64). Incremental wrap/line cache (ponytail inblocks.go). constrainViewContent—MaxWidth/MaxHeightover full screen every View; prefer structural clamp or cheaper truncate.renderInputBoxAnimatedramp — rebuild 32lipgloss.Styles every paint; theme-cache ramp keyed by dim/accent.
hasLiveContent— whencur == nil, scans all blocks each 1s tick; maintaininFlightCountor checksubProgress/tail only.transcript_navfocus —invalidateAllon every Alt+Up/Down (transcript_nav.go~51); prefer local focus decoration without full cache drop.- Modal/ask overlays —
modal.gorenderModalOverlay,ask.gorenderAskOverlay: measure only when open; ensure they don’t force transcript invalidate. - Comment drift — tick 500ms vs 1s; keep docs/tests aligned.
- Idle: pty byte count over ~2s → 0.
- Busy long stream: CPU +
SetContent/refreshrate; spinner stays ~10 FPS. - Many tools: no full-cache rebuild per
tool_resultif narrowed. go test ./tui/...- Diff limited to
tui/(+ tests).
| File | Symbols to audit |
|---|---|
tui/main.go |
session, Init, Update, tick, waitForEvent, sendCore, startCore |
tui/render.go |
relayoutHeights, layout, View, constrainViewContent, renderInputBox*, *Height helpers, chrome renderers |
tui/blocks.go |
push, invalidateAll, renderBlocks, scheduleStreamRefresh, refresh, renderBlock, renderBlockFull, hasLiveContent, maxBlocks |
tui/markdown.go |
renderMarkdown, renderMarkdownLine |
tui/handlers.go |
delta/thinking → scheduleStreamRefresh; tool_* → invalidateAll/layout; ~36 layout() |
tui/tool_blocks.go |
per-tool body renderers |
tui/goal_ux.go |
renderGoalProgressPanel, goalProgressPanelHeight |
tui/mention.go |
renderMentionFlyout, mentionFlyoutHeight |
tui/transcript_nav.go |
moveTranscriptFocus + invalidate |
tui/modal.go / ask.go / sudo.go |
overlays |
tui/keybinds.go |
dispatch (not hot CPU; don’t add per-frame work) |
tui/update.go |
launchUpdateCheck Send-before-Run |
tui/extras.go |
historyMax |
tui/protocol.go |
tickMsg, subProgressEntry, event types |
- Start:
tui/render.go— replace render-to-measure inrelayoutHeights/*Heightwith cached or structural heights so busy frames stop double-building animated input + panels. Then re-measure View CPU. - Next: classify each of the 36
handlers.golayout()sites: resize-width vs height-only → demote height-only torelayoutHeights+refresh. - Next: narrow
tool_resultinvalidateAll; fixrenderBlocksline-offset /String()copies; theme-cache animated ramp. - Track findings in
docs/tui-perf-findings.md(severity, file:symbol, status fixed/wontfix/deferred). - Re-audit until zero new P0/P1 (or documented wontfix); run
go test ./tui/...; preserve BT v2 + idle-zero-redraw invariants. - Do not re-fix already-landed spinner/idle, maxBlocks copy-trim, stream coalesce, or View-side
relayoutHeightsunless regressions appear.
Meta-prompt for implementer:
You are fixing Go TUI perf in
tui/(Bubble Tea v2). Readdocs/tui-perf-surface.md. Prefer height caches over render-to-measure; demotelayout()when wrap width unchanged; narrowinvalidateAllon tool_result. Keep idle redraw at zero; no new always-on timers; View returnstea.View; never syncprog.SendbeforeRun. Append every issue todocs/tui-perf-findings.md. Tests:go test ./tui/.... Diff only undertui/.