Skip to content

Feat/tui2 phase3 parity - #187

Merged
buchenberg merged 45 commits into
mainfrom
feat/tui2-phase3-parity
Aug 8, 2026
Merged

Feat/tui2 phase3 parity#187
buchenberg merged 45 commits into
mainfrom
feat/tui2-phase3-parity

Conversation

@buchenberg

@buchenberg buchenberg commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features
    • Added command-palette actions for stop, steer, follow-up, search, verbose mode, banners, and navigation.
    • Added message search, ephemeral notifications, live server status, and token/character counters.
    • Follow-ups can now be submitted while responses are streaming.
  • Usability Improvements
    • Improved scrolling, focus restoration, model selection, and streaming display performance.
    • Added clearer Markdown styling and refreshed light/dark theme visuals.
  • Bug Fixes
    • Corrected compaction behavior, stop handling, hotkeys, and approval dialog focus.
    • Improved message formatting and streaming continuity.

…wup, search, verbose

- Strip single-char hotkeys (?, /, j, k, g, G) — Ctrl+ only per design decision
- Add :help, :search, :top, :bottom, :verbose, :stop, :steer, :banner commands
- Fix :compact to call OnCompact → sess.Compact() (was CollapseAll bug)
- Wire OnSteer/OnFollowUp/OnStop callbacks in agent frame
- Enter-while-streaming → follow-up (submitFollowUp)
- :steer <text> for mid-turn injection
- :stop to abort running agent + hide thinking
- :search <query> scrolls to first match + ephemeral result
- :verbose toggles verbose mode
- :banner toggles banner visibility
- SetEphemeral() displays temporary messages in info pane (3s timeout)
- Update plan doc for Phase 3 progress
- Stop passing viewport width to tviewmd (was causing table overflow + garbled wrapping). tviewmd only needs Width for table column math, not word wrap — tview's SetWrap handles that.
- Command palette: use centered bordered modal (same pattern as old help component) instead of full-screen overlay. Escape now closes the palette.
- Help modal: rebuilt with same centered pattern, lists all keybindings + commands.
- Revert raw markdown storage (Phase 2.4): convItem.text now stores
  rendered output, not raw markdown. renderMarkdown() is called once
  at flush time, not on every 200ms refresh.
- Fixes garbled/interleaved output during streaming: the old code
  re-rendered ALL accumulated markdown every 200ms via the spinner
  ticker, producing corrupted output as partial markdown changed shape.
- Add border + title to messages TextView ('Conversation').
- Pass real version string to tui2.New(version) for info pane display.
- Command palette uses centered bordered modal (same as old help.Show).
- Help modal lists all keybindings + command palette commands.
Store raw text (markdown or plain) in convItem.text with isMarkdown flag.
Render lazily in refreshMessages() — markdown rendered once and cached
by viewport width. Cache invalidates on width change (resize reflow).
Plain text (user messages, system notices) used directly.
Fixes garbled output by avoiding re-render on every 200ms tick.
- CRITICAL: Fix data race on subagentBlocks — move iteration inside QueueUpdateDraw
- CRITICAL: Fix broken style tags in messages/* sub-packages (raw Dim/Accent without brackets)
- HIGH: Move spinner to info bar (updateInfoBar); ticker no longer calls refreshMessages
- HIGH: Throttled refresh from TokenDeltaEvent (150ms) instead of blind 200ms timer
- HIGH: Fix modelpicker focus restore — pass focusAfter param, call app.SetFocus on dismiss
- HIGH: Fix approval focus restore — app.SetFocus(modal) on show
- MEDIUM: All overlay pages now use resize=false (tview best practice for modals)
- Cache renderMarkdown output by convItem width; only re-render on width change
Group fields into three documented sections:
- Set before Run() — tview infrastructure, theme, version
- QueueUpdateDraw ONLY — conversationLog, subagentBlocks, pendingTokens,
  toolBlocks, reasoningBlocks, lastRefresh, ephemeralMsg, and all other
  mutable state
- Atomic — isStreaming
Clear wall comment marks the boundary to prevent future data races.
Flex wrappers with spacer items need resize=true to fill the screen and
center content. resize=false only works for tview.NewModal() (approval).
Reverted for command palette, help, question, and modelpicker overlays.
- Remove renderMarkdown calls: raw text flows directly to TextView
- Remove convItem cached/cachedWidth fields (no longer needed)
- Remove TokenDeltaEvent throttle (ticker handles refresh timing)
- Restore refreshMessages() in spinner ticker with isStreaming gate
- Keep messageWidth() for RenderCtx.Width (component borders still sized)
- If garbling persists without tviewmd, the issue is in tview/TextView layer
Streaming tokens now start one blank line below the last conversation
block instead of appearing directly on the next line without spacing.
- Arrow-navigable list of all commands with descriptions
- Enter to execute, Escape to dismiss
- Same centered modal pattern as help
- Removes old InputField-based palette (single-line, unreadable)
…tText()

Root cause confirmed: SetText() on the full conversation buffer every 200ms
corrupts tview's TextView rendering, even with raw text (no tviewmd).
The fix:
- TokenDeltaEvent: Write() appends raw token text directly to TextView
- Ticker: skip refreshMessages() during streaming (no SetText rebuild)
- Flush boundaries: flushPendingTokens + refreshMessages replaces Write'd
  tokens with properly ordered conversation entries via SetText
- Streaming text now appears in real-time without 200ms rebuild cycle
The reviewer trace showed 114 powershell calls vs 0 read calls,
causing context bloat, crippling prune overhead (25-32s), and eventual
timeout at 50 iterations. Added explicit tool-selection guidance:
prefer dedicated file tools (read/grep/glob/ls/file_info) over shell
subprocesses for efficiency and context hygiene.
…ruption

Root cause of persistent garbled output: raw markdown text contains
[...] patterns (links, code references, bracketed terms) that tview's
SetDynamicColors parser consumes as color/style directives, silently
swallowing or miscoloring surrounding text.

Fix: escapeBrackets() converts '[' to '[[]' (tview literal-escape
sequence) before storing text in convItem or writing to TextView.
Applied to:
- addAssistantResponse: escaped before convItem storage
- TokenDeltaEvent Write(): escaped before streaming to TextView
…ors bracket parsing

The real fix for garbled text, confirmed by tui1's approach: render
markdown BEFORE it reaches the TextView. tviewmd produces valid tview
color tags — no raw [...] brackets survive to be consumed as directives.

- Restore renderMarkdown with width-based convItem caching
- Streaming: Write() raw tokens with bracket escaping (safe during stream)
- Flushed: renderMarkdown via cache, re-render on width change (resize reflow)
- Keep Write()/SetText() streaming fix (no 200ms rebuild during stream)

This matches tui1's Message{Content: renderMarkdown(raw), Raw: raw} pattern.
Borders on the messages pane were eating inner-rect space, causing
renderMarkdown to compute wrong width for table column math. Tables
overflowed, tview wrapping broke column-aligned text mid-word.
Removing borders eliminates the width miscalculation.

Also removed dead placeholder text from infopane.Build() and unused
imports in infopane/todo packages.
…urce

Remove ::b and ::d modifiers from mdTheme to determine if underline
comes from theme styles or tviewmd's internal heading renderer.
Also remove leftover escapeBrackets from streaming Write() path.
The indicator used colors.Dim (#5f5f5f::d — dark gray faint) which was
nearly invisible on dark backgrounds. Removed the color wrapper so the
lolcat rainbow text renders at default brightness, matching tui1's
visible thinking indicator.
Pass the full '  ⠋ Thinking...' string through lolcat.Rainbow instead
of only the label. Matches tui1's lolcatRender() which colors the
entire line including the spinner frame.
The pre-token spinner now shows 'Thinking...' (matching tui1).
Reasoning blocks still show 'Reasoning...' for actual reasoning content.
This gives the same dual-indicator pattern: Thinking for the wait phase,
Reasoning for the analysis phase.
…ttern

Replace string += with strings.Builder for streaming token accumulation.
Tui1 uses strings.Builder for streamContent; the += pattern creates O(n^2)
allocations that may cause memory fragmentation under rapid token arrival.
Also restore renderMarkdown for flushed content.
TokenDeltaEvent uses broker.Publish() which silently drops events when
the subscriber channel (buffer 4096) is full. Without Write(), tokens
accumulate until flush, clogging the channel. Once full, new tokens are
dropped — causing character loss in the final response text that gets
worse over longer conversations.

Write() drains the broker channel via fast terminal append, preventing
the buffer from filling up. The slow flush/SetText path still handles
the proper rendering on flush boundaries.
Instrument TokenDeltaEvent handler (tui2.token) and refreshMessages
(tui2.refresh) with OTel spans. Token span carries the text attribute
so we can see exact token content in SigNoz. Also add atomic counters
(tokensRx, charsWritten, charsRendered) for future diagnostic display.
QueueUpdateDraw triggers ForceDraw() after every callback, which
redraws the entire screen. During streaming (~100 tokens/sec), this
floods the draw pipeline and the 100-slot update queue backs up,
blocking HandleEvent, blocking the broker subscriber, causing
silent event drops (→ garbled text).

QueueUpdate appends to the buffer without ForceDraw. The ticker
(200ms) handles screen redraws. Reduces draw pressure from ~100/sec
to ~5/sec, preventing queue contention and broker drops.
…ounters

Per-token OTel spans create 100+ allocations/sec during streaming.
Replaced with zero-overhead atomic counters: tokensRx, charsWritten,
charsRendered. Counters exposed as span attributes on tui2.refresh
(fires at most 200ms). charsRendered tracked in refreshMessages.
Your edits in d802e8b removed the explicit 'Avoid powershell and bash
for file reading' guidance. Without it, reviewers defaulted to shell
commands — 75 powershell calls vs 20 reads in the 'Core Framework'
reviewer. Re-adding the guidance that was dropped.
Shell tools are the root cause of every reviewer timeout — reviewers use
them for file reading (75 powershell calls vs 20 reads in latest trace)
causing context bloat and prune overhead. No dedicated tools missing:
read/grep/glob for file inspection, git/diff for history, staticcheck
for analysis, go_outline for structure. webfetch/http also removed as
irrelevant to code review.
Add rx/wr/rn counters to the status bar during streaming: tokens
received (rx), characters written via Write() (wr), and characters
in the last full render (rn). If rx >> wr, the broker is dropping
events. SigNoz flat export drops custom span attributes, so status
bar display is the reliable path for live diagnosis.
Creating a standalone trace (context.Background) every 200ms produces
5 new traces/second — wasted overhead. The status bar diagnostic
counters (rx/wr/rn) give better live visibility with zero allocation.
Status bar is only updated on DoneEvent — invisible during streaming.
Info pane refreshes every 200ms via the ticker during active streaming,
so the rx/wr/rn counters are always visible. Also removes unused
UpdateDiagnostic from statusbar package.
One tui2.flush span per flush boundary (at most ~10/response, not per
tick). Captures tokens_rx, chars_written, chars_rendered, and
pending_len. Compare: if chars_written < tokens_rx, broker drops.
If chars_rendered < pending_len, render pipeline loses text.
Not in hot path — fires only at flush, not 5/sec like the removed
refresh span.
guardContextBeforeCall fired compactContext on every turn regardless
of pipeline config. CompactionMiddleware.PrepareStep already handles
this for loops with the middleware pipeline. Subagents have nil
middleware, so compaction is now properly disabled — reviewers won't
lose their working memory mid-analysis from unwarranted compaction.

This replaces the NoCompaction field approach: instead of adding a new
flag to suppress the duplicate, we simply remove the duplicate and let
the middleware pipeline own compaction end-to-end.
Streaming tokens are raw markdown containing [...] patterns that
tview's SetDynamicColors consumes as color directives, causing text
to disappear during streaming. The flush replaces everything with
renderMarkdown output (valid tview tags), so brackets only need
escaping in the Write() path. Final rendered output is unaffected.
… turns

SendHeartbeat fires only at loop iteration boundaries. A hung LLM call
(>60s, zero tokens) kills the subagent via stuck_child_timeout despite
the subagent being actively waiting for a response.

Now the stream handler emits throttled heartbeats (every 5s) while
tokens arrive. A hung stream with no tokens still times out, but any
stream producing tokens resets the watchdog, preventing false-positives
on slow-but-productive LLM calls.
Replace raw bracket-escaped Write() with tviewmd.RenderPartial which
line-buffers: complete lines render through goldmark, the last
incomplete line shows as escaped raw text. On flush, Render replaces
everything with fully formatted output.

This gives live markdown rendering during streaming — headings, lists,
code blocks, and tables appear incrementally as lines complete, instead
of showing escaped raw brackets until flush.

Also pin tviewmd to local via replace directive (sibling repo).
RenderPartial on full pendingTokens via Write() double-appended: each
token triggered a full render+append of all accumulated text. Now:
- Write(): bare token bytes (fast, streaming)
- refreshMessages(): RenderPartial on pendingTokens with custom theme
  (renders complete lines, holds back incomplete as raw text)
- Flush: Render (full goldmark with theme)

Incremental rendering is correct: complete lines appear formatted,
incomplete line shows as raw text until the newline arrives.
…wer budgets

Reviewers hitting max_iterations (50) produce incomplete results
because they run out of iterations mid-analysis. New guidance tells
the orchestrator to split large review areas into smaller focused
tasks: 'Review theme.go and colors/' not 'Review all of tui2'.
Sub-agents previously got WrapUpThreshold=1 — one turn of warning
before hitting max_iterations. Reviewers need more time to synthesize
findings when they approach their budget. Default 5 gives reviewers
5 turns of advance notice to wrap up and produce a structured contract.

Adds WrapUpThreshold to SubAgentConfig and LoopConfig wiring.
Streaming: remove Write() and RenderPartial — just show Thinking...
spinner during token arrival. Flushed content renders via renderMarkdown
as before. Eliminates garbled streaming output by deferring all
rendering to flush boundaries.

Scroll snapback: track userScrolled flag set on Up/PgUp/Top, cleared
on Down/PgDn/Bottom. refreshMessages only auto-ScrollToEnd when
userScrolled is false. Prevents the view from yanking back to the
tail when the user manually scrolls up to read overflow content.
Mouse scroll up sets userScrolled=true (prevents auto-snap on next
refresh). Mouse scroll down clears it only when already at the
bottom of the conversation. Works alongside keyboard scroll tracking.
Multiple reviewers shared the same Role string, causing AddSubAgentEnd
and AddSubAgentError to always target the first block. Subsequent
subagents with the same role never received completion/failure
updates — the first block absorbed all events.
Width -2 prevents horizontal rules and tables from bleeding into
adjacent panes. Empty inline code background uses inherited color
instead of 'default' which may be misinterpreted by tview.
…old close tag

- infopane.Build() now accepts Theme and sets a purple border (#af5fff)
- Added PaneBorder field to Theme struct
- MCP section wired to mcpinfo.Format() instead of hardcoded dash
- TagBold close tag changed from [-] to [-:-:-] to reset bold flag
- Removed unused escapeBrackets function
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@buchenberg, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 95b14d3a-3513-4381-8fb3-a02c8af0c244

📥 Commits

Reviewing files that changed from the base of the PR and between 92235de and 8e2ead7.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (1)
  • internal/tui2/tui2.go
📝 Walkthrough

Walkthrough

This change advances TUI2 Phase 3. It adds command-palette actions, Ctrl-based input handling, streaming metrics and rendering updates, scrolling and search, agent steer/follow-up/stop callbacks, heartbeat notifications, configurable wrap-up thresholds, and middleware-owned compaction.

Changes

TUI2 hardening

Layer / File(s) Summary
Agent runtime integration
internal/agent/llm/stream.go, internal/agent/subagent_loop.go, internal/agent/turn.go, cmd/yaah/tui2.go
Streaming emits rate-limited heartbeats. Sub-agent loops accept a wrap-up threshold. Context compaction is delegated to middleware. TUI2 forwards steer, follow-up, and stop actions to the agent session.
TUI2 input and component contracts
internal/tui2/components/command/command.go, internal/tui2/keymap.go, internal/tui2/colors/theme.go, internal/tui2/components/*
Command parsing and Ctrl-based bindings support new actions. Modals restore focus. Themes, pane borders, Markdown tags, status indicators, and message prefixes are updated.
TUI2 streaming and interaction flow
internal/tui2/tui2.go, internal/tui2/events.go, internal/tui2/control.go, internal/tui2/helpers_msg.go, internal/tui2/markdown.go, go.mod
TUI2 handles streaming tokens, cached Markdown, scrolling, search, banners, ephemeral messages, command dispatch, follow-ups, and live MCP status. The tviewmd dependency is upgraded.
Plan and reviewer configuration
.agents/plans/tui2-hardening/PLAN.md, internal/prompts/identity.md, internal/prompts/roles/reviewer.md
The plan records Phase 3 progress and remaining gaps. Reviewer guidance defines focused review scope, tools, timeout, and non-modification rules.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the TUI2 Phase 3 parity work, which matches the primary changes in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tui2-phase3-parity

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/agent/turn.go (1)

80-91: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Report the pre-prepare message count in the error text.

Inside this branch len(req.Messages) is always 0, so the error always reports "0 messages after prepare". Use len(*messages) to make the error actionable. The doc comment also breaks mid-identifier; move the period inside the parentheses.

🛠️ Proposed fix
-	// Compaction is handled by the middleware pipeline (CompactionMiddleware.
-	// PrepareStep). guardContextBeforeCall only validates that the request
-	// is not empty — it does not trigger compaction.
+	// Compaction is handled by the middleware pipeline
+	// (CompactionMiddleware.PrepareStep). guardContextBeforeCall only
+	// validates that the request is not empty — it does not trigger compaction.
 	if len(req.Messages) == 0 {
-		err := fmt.Errorf("refusing to send empty message list to provider — %d messages after prepare", len(req.Messages))
+		err := fmt.Errorf("refusing to send empty message list to provider — %d messages before prepare", len(*messages))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/turn.go` around lines 80 - 91, Update the empty-message error
in the guardContextBeforeCall branch to report len(*messages), preserving the
post-prepare context while exposing the pre-prepare count. Also fix the
preceding comment punctuation so “CompactionMiddleware.PrepareStep” remains
intact inside the parentheses.
internal/tui2/tui2.go (1)

654-662: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The toggle checks the wrong page name, so Ctrl+P stacks duplicate modals.

toggleCommandPalette tests HasPage("cmdpalette_modal"), but showCommandList registers the page as "cmdlist_modal" (Line 581). The condition is therefore always false. Each Ctrl+P press calls showCommandList again and pushes another page onto t.Pages. The user must press Escape once per press to unwind them.

t.CmdPalette is also built in buildUI (Line 234) but is no longer added to any page, so the cmdpalette_modal name is now unreachable.

🐛 Proposed fix
+const cmdListModal = "cmdlist_modal"
+
 func (t *TUI2) toggleCommandPalette() {
-	const cmdModal = "cmdpalette_modal"
-	if t.Pages.HasPage(cmdModal) {
-		t.Pages.RemovePage(cmdModal)
+	if t.Pages.HasPage(cmdListModal) {
+		t.Pages.RemovePage(cmdListModal)
 		t.App.SetFocus(t.Input)
 		t.focus = focusNormal
 	} else {
 		t.showCommandList()
 	}
 }

Then use cmdListModal in showCommandList in place of its local modalName, and remove the now-unused t.CmdPalette wiring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 654 - 662, Update toggleCommandPalette to
check the page identifier used by showCommandList, cmdListModal, so repeated
Ctrl+P presses remove the existing command-list modal instead of stacking
duplicates. In buildUI, remove the obsolete t.CmdPalette wiring now that it is
not registered on any page.
🧹 Nitpick comments (8)
internal/tui2/keymap.go (1)

49-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a different key for ActionToggleSubAgents.

Many terminals consume Ctrl+S as XOFF (software flow control) and never deliver it to the application. On those terminals the binding appears dead and the display freezes until the user presses Ctrl+Q. Ctrl+G or Ctrl+B avoids the conflict.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/keymap.go` at line 49, Change the key binding for
ActionToggleSubAgents in the keymap definition from Ctrl+S to a non-flow-control
key such as Ctrl+G or Ctrl+B, updating its displayed Label to match while
preserving the action and help text.
internal/tui2/tui2.go (4)

176-201: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

The idle check does not prevent the redraw.

QueueUpdateDraw always calls Draw after the callback returns, including when the callback returns early at Line 187. The TUI therefore still repaints the full screen five times per second while completely idle. Use QueueUpdate and request the draw only when work happened.

♻️ Proposed refactor
-			t.App.QueueUpdateDraw(func() {
+			t.App.QueueUpdate(func() {
 				anyActive := t.thinkingInd.Visible() || t.isStreaming.Load()
@@
 				if !anyActive {
 					return
 				}
@@
 				if !t.isStreaming.Load() {
 					t.refreshMessages()
 					t.renderInfoPane()
 				}
+				t.App.Draw()
 			})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 176 - 201, Replace the unconditional
redraw flow around the ticker callback with QueueUpdate, track whether the
callback performed animation or refresh work, and call Draw only when activity
was detected. Preserve the existing idle early-return behavior and spinner,
subagent, refreshMessages, and renderInfoPane updates for active states.

88-105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The comment block places atomic counters under "QueueUpdateDraw only".

tokensRx, charsWritten, and charsRendered are atomic.Int64 and are updated outside the queue. events.go:18 calls t.tokensRx.Add(1) before entering QueueUpdate. The types make that safe, but the section header states the opposite rule. Move the three counters into the "Atomic fields" group so the documented invariant matches the code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 88 - 105, Move tokensRx, charsWritten,
and charsRendered out of the streaming-state group and into the Atomic fields
group in the TUI state definition. Update the section placement so the
streaming-state header applies only to QueueUpdateDraw-managed fields, while
preserving the existing atomic counter types and usage.

559-561: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove or implement CmdVerbose. t.verbose is only toggled and never read, so the command causes no visible change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 559 - 561, Implement visible verbose-mode
behavior for the CmdVerbose branch by making t.verbose affect message rendering
or refresh behavior, and ensure t.refreshMessages() reflects the updated
setting; alternatively remove the CmdVerbose case and its unused t.verbose state
if verbose mode is not needed.

214-227: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Replace the full-buffer copy and split with GetWrappedLineCount(). tview.TextView v0.42.0 has no GetLineCount() method. GetOriginalLineCount() ignores wrapping, while GetWrappedLineCount() reuses the internal wrapped-line index and avoids repeated buffer and slice allocations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 214 - 227, Update the MouseScrollDown
branch in the SetMouseCapture callback to use t.Messages.GetWrappedLineCount()
instead of retrieving the full text and splitting it with strings.Split. Compare
the wrapped-line count with row+h to preserve the existing userScrolled reset
behavior, and remove the now-unused strings dependency if applicable.
internal/tui2/events.go (1)

131-138: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This span records nothing useful.

Start is immediately followed by End, so the span has a duration near zero and measures no operation. It also uses context.Background(), so it never attaches to the active turn span and appears as an orphan root in the trace. A counter metric or a span that wraps addAssistantResponse would carry real information.

♻️ Proposed refactor
-	_, span := otel.Tracer("yaah").Start(context.Background(), "tui2.flush",
-		trace.WithAttributes(
-			attribute.Int64("tokens_rx", t.tokensRx.Load()),
-			attribute.Int64("chars_written", t.charsWritten.Load()),
-			attribute.Int64("chars_rendered", t.charsRendered.Load()),
-			attribute.Int("pending_len", len(raw)),
-		))
-	span.End()
-
-	t.addAssistantResponse(raw)
+	_, span := otel.Tracer("yaah").Start(context.Background(), "tui2.flush",
+		trace.WithAttributes(
+			attribute.Int64("tokens_rx", t.tokensRx.Load()),
+			attribute.Int64("chars_written", t.charsWritten.Load()),
+			attribute.Int64("chars_rendered", t.charsRendered.Load()),
+			attribute.Int("pending_len", len(raw)),
+		))
+	t.addAssistantResponse(raw)
+	span.End()

Hoist otel.Tracer("yaah") into a package-level variable so the flush path does not look up the tracer on every call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/events.go` around lines 131 - 138, Remove the immediately-ended
span in the flush path, or move span creation to wrap the actual
addAssistantResponse operation using the active context instead of
context.Background(). If retaining tracing, hoist otel.Tracer("yaah") into a
package-level tracer variable and ensure the recorded attributes describe the
wrapped work.
internal/tui2/components/command/command.go (1)

52-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the prefix table so Parse and the argument extraction cannot drift.

Each argument command now declares its prefix twice: once in Parse and once in the SetDoneFunc switch. If someone renames followup in one place only, the command parses but loses its argument. A shared table removes that risk.

♻️ Proposed refactor
var argCommands = []struct {
	prefix string
	cmd    Cmd
}{
	{"model ", CmdModel},
	{"steer ", CmdSteer},
	{"followup ", CmdFollowUp},
	{"search ", CmdSearch},
}

// ParseArg returns the command and its argument.
func ParseArg(input string) (Cmd, string) {
	input = strings.TrimSpace(input)
	cmd := Parse(input)
	for _, ac := range argCommands {
		if cmd == ac.cmd && strings.HasPrefix(input, ac.prefix) {
			return cmd, strings.TrimPrefix(input, ac.prefix)
		}
	}
	return cmd, ""
}

Also applies to: 119-128

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/components/command/command.go` around lines 52 - 67, Extract
the argument-command prefixes used by Parse and SetDoneFunc into one shared
argCommands table containing each prefix and Cmd value. Add or update ParseArg
to trim input, parse the command, and derive the argument from that shared
table, then use it for argument extraction so commands such as CmdModel,
CmdSteer, CmdFollowUp, and CmdSearch cannot drift between parsing and argument
handling.
cmd/yaah/tui2.go (1)

111-123: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Reuse the stop callback

OnStop duplicates OnAbort; define one stopAgent callback and assign it to both fields. Steer and FollowUp use non-blocking channel operations, so they do not need goroutines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/yaah/tui2.go` around lines 111 - 123, In the callback setup around
app.OnSteer, app.OnFollowUp, and app.OnStop, define a single stopAgent callback
containing the existing HideThinking and cancelAgent cleanup, then assign it to
both app.OnStop and app.OnAbort. Keep Steer and FollowUp as direct synchronous
callback bodies without adding goroutines.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.agents/plans/tui2-hardening/PLAN.md:
- Around line 457-464: Replace the Markdown checkbox entries under “Remaining
items” with references to the corresponding Beads issues, preserving this
section as a concise status summary. Use existing issue IDs where available and
do not retain any `☐` TODO-list syntax.
- Around line 480-481: Synchronize the B.1/B.2 feature-inventory rows with the
resolved statuses recorded in the “Ctrl+T conflict” and “:compact semantic bug”
entries: mark Ctrl+T as resolved with toggle tools/reasoning assignments, and
mark :compact as fixed via OnCompact calling sess.Compact().
- Around line 426-455: Update the Phase 3 parity inventory and exit gate to
distinguish the completed items from remaining unimplemented parity work,
explicitly marking unfinished items as deferred or keeping Phase 3 open until
they are delivered. Ensure the documented completion status and exit criteria
remain consistent.

In `@go.mod`:
- Around line 84-85: Remove the local replace directive for
github.com/buchenberg/tviewmd from go.mod and restore the published
github.com/buchenberg/tviewmd v0.1.0 dependency so module downloads work without
a sibling ../tviewmd checkout.

In `@internal/agent/llm/stream.go`:
- Around line 53-59: Update the stream handling logic in SendStream to emit
heartbeats independently of stream deltas by adding a ticker case to its select
loop, using a cadence shorter than StuckChildTimeout and invoking the existing
heartbeat mechanism. Preserve delta processing while ensuring reasoning-only,
tool-call-only, and idle waits reset the watchdog.

In `@internal/agent/subagent_loop.go`:
- Line 30: Update the sub-agent loop options construction in runner.go to pass
opts.defaults.WrapUpThreshold into WrapUpThreshold instead of allowing the
default value of 5 to replace configured values. Preserve negative thresholds
unchanged so wrap_up_turns < 0 disables wrap-up notices, and add tests covering
both a positive configured threshold and a negative disabled value.

In `@internal/prompts/roles/reviewer.md`:
- Around line 33-34: Update the reviewer role description to use the hyphenated
compound modifier, changing “easy to maintain code” to “easy-to-maintain code”
while preserving the rest of the wording.

In `@internal/tui2/colors/theme.go`:
- Line 154: Update the package-level TagBold helper in colors.go to use the full
style reset after the ::b attribute, matching the reset behavior in the
theme-level helper. Preserve its existing prefix and formatting so
sessioninfo.Format and mcpinfo.Format no longer leave following text bold.

In `@internal/tui2/components/command/command.go`:
- Around line 78-79: Update the command matching case in the command parser to
accept “model” only when the input is exactly the bare command or begins with
“model ” followed by arguments; reject inputs such as “models” and “modelx”
while preserving CmdModel for valid argument forms.

In `@internal/tui2/components/infopane/infopane.go`:
- Around line 9-19: Update Build in the infopane component to apply the border
and title colors only when the theme is non-nil and NoColor is false, matching
input.Build’s guard behavior; leave the default TextView styling unchanged
otherwise.

In `@internal/tui2/control.go`:
- Around line 100-107: Move the “Ephemeral messages (search results, notices)”
comment to the block beginning at the next ephemeral-message rendering section,
and update the token counter’s `wr` value in the surrounding render method to
use the actually maintained write-count metric identified in
`internal/tui2/events.go` instead of `t.charsWritten`, preserving the existing
counter label and formatting.

In `@internal/tui2/events.go`:
- Around line 17-23: Update the TokenDeltaEvent handling in the event switch so
each token delta is rendered in Messages while streaming, either by appending
the pending token content to the displayed messages or by updating the refresh
path to include pendingTokens. Ensure the rendered output stays synchronized
with pendingTokens; changing ticker refresh behavior alone is insufficient.

In `@internal/tui2/helpers_msg.go`:
- Around line 11-16: Update addAssistantResponse and appendMessage so they call
t.App.SetFocus(t.Input) only when the main page is currently on top; preserve
message appending and refresh behavior regardless of modal state, and leave
focus on approval or question modals when either is active.

In `@internal/tui2/markdown.go`:
- Around line 10-25: Move the mdTheme definition out of package scope and into
renderMarkdown, or construct it through a helper called by renderMarkdown before
tviewmd.Render. Preserve all existing theme values and rendering behavior.

In `@internal/tui2/tui2.go`:
- Around line 801-814: Update TUI2.searchMessages to derive the match index and
newline count from the same lowercased text value, avoiding slicing the original
text with an offset from a transformed string. Also convert the logical line
containing the match into the wrapped display row expected by
t.Messages.ScrollTo, accounting for SetWrap(true) and long lines.
- Around line 816-826: Update TUI2.SetEphemeral to prevent older delayed clears
from removing newer messages: add an ephemeralSeq field beside ephemeralMsg,
increment it on each call, capture the current sequence in the goroutine, and
clear/render only when the captured sequence still matches the latest value.
- Around line 583-614: Update the command-list callback around entries and
HandleCommand so argument-required commands prompt for input before dispatching.
Use the existing command.Palette input through promptForArg for CmdSteer and
CmdSearch, then pass the entered value to t.HandleCommand; keep no-argument
commands unchanged and add the missing CmdFollowUp entry.
- Around line 509-512: Update the ActionScrollUp/ActionPageUp/ActionTop and
ActionScrollDown/ActionPageDown/ActionBottom handling in globalInputCapture to
change userScrolled only when t.Messages has focus. Leave arrow-key events
directed at t.Input from modifying the auto-scroll state.
- Around line 780-799: Update toggleBanner to keep Header in its original Root
flex position by using ResizeItem rather than removing and re-adding it. Store
the line count returned by banner.Build() and have headerHeight use that stored
value, since Banner.GetInnerRect() is zero after collapse.

---

Outside diff comments:
In `@internal/agent/turn.go`:
- Around line 80-91: Update the empty-message error in the
guardContextBeforeCall branch to report len(*messages), preserving the
post-prepare context while exposing the pre-prepare count. Also fix the
preceding comment punctuation so “CompactionMiddleware.PrepareStep” remains
intact inside the parentheses.

In `@internal/tui2/tui2.go`:
- Around line 654-662: Update toggleCommandPalette to check the page identifier
used by showCommandList, cmdListModal, so repeated Ctrl+P presses remove the
existing command-list modal instead of stacking duplicates. In buildUI, remove
the obsolete t.CmdPalette wiring now that it is not registered on any page.

---

Nitpick comments:
In `@cmd/yaah/tui2.go`:
- Around line 111-123: In the callback setup around app.OnSteer, app.OnFollowUp,
and app.OnStop, define a single stopAgent callback containing the existing
HideThinking and cancelAgent cleanup, then assign it to both app.OnStop and
app.OnAbort. Keep Steer and FollowUp as direct synchronous callback bodies
without adding goroutines.

In `@internal/tui2/components/command/command.go`:
- Around line 52-67: Extract the argument-command prefixes used by Parse and
SetDoneFunc into one shared argCommands table containing each prefix and Cmd
value. Add or update ParseArg to trim input, parse the command, and derive the
argument from that shared table, then use it for argument extraction so commands
such as CmdModel, CmdSteer, CmdFollowUp, and CmdSearch cannot drift between
parsing and argument handling.

In `@internal/tui2/events.go`:
- Around line 131-138: Remove the immediately-ended span in the flush path, or
move span creation to wrap the actual addAssistantResponse operation using the
active context instead of context.Background(). If retaining tracing, hoist
otel.Tracer("yaah") into a package-level tracer variable and ensure the recorded
attributes describe the wrapped work.

In `@internal/tui2/keymap.go`:
- Line 49: Change the key binding for ActionToggleSubAgents in the keymap
definition from Ctrl+S to a non-flow-control key such as Ctrl+G or Ctrl+B,
updating its displayed Label to match while preserving the action and help text.

In `@internal/tui2/tui2.go`:
- Around line 176-201: Replace the unconditional redraw flow around the ticker
callback with QueueUpdate, track whether the callback performed animation or
refresh work, and call Draw only when activity was detected. Preserve the
existing idle early-return behavior and spinner, subagent, refreshMessages, and
renderInfoPane updates for active states.
- Around line 88-105: Move tokensRx, charsWritten, and charsRendered out of the
streaming-state group and into the Atomic fields group in the TUI state
definition. Update the section placement so the streaming-state header applies
only to QueueUpdateDraw-managed fields, while preserving the existing atomic
counter types and usage.
- Around line 559-561: Implement visible verbose-mode behavior for the
CmdVerbose branch by making t.verbose affect message rendering or refresh
behavior, and ensure t.refreshMessages() reflects the updated setting;
alternatively remove the CmdVerbose case and its unused t.verbose state if
verbose mode is not needed.
- Around line 214-227: Update the MouseScrollDown branch in the SetMouseCapture
callback to use t.Messages.GetWrappedLineCount() instead of retrieving the full
text and splitting it with strings.Split. Compare the wrapped-line count with
row+h to preserve the existing userScrolled reset behavior, and remove the
now-unused strings dependency if applicable.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3bd0b1b8-b7ad-447d-aefe-049fca174239

📥 Commits

Reviewing files that changed from the base of the PR and between 3c5fbc8 and b82eacf.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (27)
  • .agents/plans/tui2-hardening/PLAN.md
  • cmd/yaah/tui2.go
  • go.mod
  • internal/agent/llm/stream.go
  • internal/agent/subagent_loop.go
  • internal/agent/turn.go
  • internal/prompts/identity.md
  • internal/prompts/roles/reviewer.md
  • internal/tui2/colors/theme.go
  • internal/tui2/components/approval/approval.go
  • internal/tui2/components/command/command.go
  • internal/tui2/components/infopane/infopane.go
  • internal/tui2/components/messages/error/error.go
  • internal/tui2/components/messages/messages.go
  • internal/tui2/components/messages/subagent/subagent.go
  • internal/tui2/components/messages/system/system.go
  • internal/tui2/components/messages/tool/tool.go
  • internal/tui2/components/messages/user/user.go
  • internal/tui2/components/modelpicker/modelpicker.go
  • internal/tui2/components/thinking/thinking.go
  • internal/tui2/components/todo/todo.go
  • internal/tui2/control.go
  • internal/tui2/events.go
  • internal/tui2/helpers_msg.go
  • internal/tui2/keymap.go
  • internal/tui2/markdown.go
  • internal/tui2/tui2.go
💤 Files with no reviewable changes (1)
  • internal/tui2/components/todo/todo.go

Comment on lines +426 to +455

### 3.1 Ctrl-only hotkeys + command palette (✅ complete 2026-08-07)
- ✅ Stripped single-char hotkeys (`?`, `/`, `j`, `k`, `g`, `G`) from `DefaultBindings()`
- ✅ Only Ctrl+ combinations, Esc, Enter, arrows, navigation keys, Tab remain as hotkeys
- ✅ All other actions routed through command palette (`:help`, `:search`, `:top`, `:bottom`, `:verbose`, `:stop`, `:steer`, `:banner`)

### 3.2 Steer + FollowUp (✅ complete 2026-08-07)
- ✅ Wire `OnSteer`/`OnFollowUp` callbacks in `tui2.go`
- ✅ Enter-while-streaming → follow-up (submitFollowUp)
- ✅ `:steer <text>` command for mid-turn injection

### 3.3 Fix `:compact` (✅ complete 2026-08-07)
- ✅ `:compact` now calls `OnCompact` → `sess.Compact()` instead of `CollapseAll`

### 3.4 Search (✅ complete 2026-08-07)
- ✅ `:search <query>` scrolls to first match in messages TextView
- ✅ Ephemeral result message in info pane

### 3.5 Verbose toggle (✅ complete 2026-08-07)
- ✅ `:verbose` command toggles verbose mode
- ✅ `verbose` field on TUI2 struct

### 3.6 Stop (✅ complete 2026-08-07)
- ✅ `:stop` command calls `OnStop` → aborts running agent + hides thinking

### 3.7 Banner toggle (✅ complete 2026-08-07)
- ✅ `:banner` toggles banner visibility

### 3.8 Ephemeral messages (✅ complete 2026-08-07)
- ✅ `SetEphemeral(msg)` displays temporary messages in info pane (3s timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the parity exit gate consistent with the documented scope.

The new notes mark the priority work complete, but the plan still lists unimplemented parity items. Update the exit gate and inventory to identify deferred items, or keep Phase 3 open until they are complete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/plans/tui2-hardening/PLAN.md around lines 426 - 455, Update the
Phase 3 parity inventory and exit gate to distinguish the completed items from
remaining unimplemented parity work, explicitly marking unfinished items as
deferred or keeping Phase 3 open until they are delivered. Ensure the documented
completion status and exit criteria remain consistent.

Comment on lines +457 to +464
### Remaining items
- ☐ Click-to-expand via tview regions
- ☐ Clipboard: `:copyview` + copy-last-response
- ☐ `:login`/`:logout`/`:mcp` command wiring (callbacks declared, not dispatched)
- ☐ Model picker data + filter correctness
- ☐ Min terminal size guard
- ☐ Per-block verbose collapse semantics (match tui1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Move the remaining-work checklist to Beads.

The entries form a Markdown TODO list. Replace them with Beads references and keep this file as a status summary. As per coding guidelines, use Beads (bd) for all task tracking and do not use Markdown TODO lists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/plans/tui2-hardening/PLAN.md around lines 457 - 464, Replace the
Markdown checkbox entries under “Remaining items” with references to the
corresponding Beads issues, preserving this section as a concise status summary.
Use existing issue IDs where available and do not retain any `☐` TODO-list
syntax.

Source: Coding guidelines

Comment on lines +480 to +481
- **Ctrl+T conflict**: ✅ **Resolved 2026-08-07** — Ctrl+T = toggle tools, Ctrl+R = toggle reasoning. No conflict.
- **`:compact` semantic bug**: ✅ **Fixed 2026-08-07** — now calls `sess.Compact()` via `OnCompact`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Synchronize resolved risks with the feature inventory.

These lines mark the Ctrl+T conflict and :compact bug as resolved, while the earlier B.1/B.2 tables still show them as unresolved. Update those rows in the same change so the plan has one authoritative status.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/plans/tui2-hardening/PLAN.md around lines 480 - 481, Synchronize the
B.1/B.2 feature-inventory rows with the resolved statuses recorded in the
“Ctrl+T conflict” and “:compact semantic bug” entries: mark Ctrl+T as resolved
with toggle tools/reasoning assignments, and mark :compact as fixed via
OnCompact calling sess.Compact().

Comment thread go.mod Outdated
Comment on lines +84 to +85

replace github.com/buchenberg/tviewmd => ../tviewmd

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

test -f ../tviewmd/go.mod || {
	echo "missing ../tviewmd/go.mod: the committed replacement is not reproducible" >&2
	exit 1
}

Repository: buchenberg/yaah

Length of output: 226


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- go.mod ---'
sed -n '1,130p' go.mod

printf '%s\n' '--- tracked workflow/config candidates ---'
git ls-files | rg '(^|/)(\.github/workflows/|Makefile$|Taskfile|Justfile|Dockerfile|go\.work$|scripts?/|.*\.(sh|ya?ml|yaml|toml|json))' | sed -n '1,240p'

printf '%s\n' '--- tviewmd references ---'
rg -n --hidden -g '!.git' -g '!go.sum' 'tviewmd|replace .*=>' . | sed -n '1,240p'

Repository: buchenberg/yaah

Length of output: 6883


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- .github/workflows/ci.yml ---'
cat -n .github/workflows/ci.yml

printf '%s\n' '--- .github/workflows/release-please.yml ---'
cat -n .github/workflows/release-please.yml

printf '%s\n' '--- Dockerfile ---'
cat -n Dockerfile

printf '%s\n' '--- install.sh ---'
cat -n install.sh

printf '%s\n' '--- relevant plan sections ---'
sed -n '1,75p' .agents/plans/tui2-hardening/PLAN.md
sed -n '345,385p' .agents/plans/tui2-hardening/PLAN.md
sed -n '475,505p' .agents/plans/tui2-hardening/PLAN.md

Repository: buchenberg/yaah

Length of output: 18041


Remove the local module replacement before merging. CI checks out only yaah and does not provision ../tviewmd; go mod download therefore fails. Use the published github.com/buchenberg/tviewmd v0.1.0 module without the replacement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go.mod` around lines 84 - 85, Remove the local replace directive for
github.com/buchenberg/tviewmd from go.mod and restore the published
github.com/buchenberg/tviewmd v0.1.0 dependency so module downloads work without
a sibling ../tviewmd checkout.

Comment on lines +53 to +59
var lastHeartbeat time.Time
emitHeartbeat := func() {
if time.Since(lastHeartbeat) > 5*time.Second {
tools.SendHeartbeat(ctx)
lastHeartbeat = time.Now()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

echo '--- outline ---'
ast-grep outline internal/agent/llm/stream.go --view expanded || true

echo '--- stream.go relevant ranges ---'
nl -ba internal/agent/llm/stream.go | sed -n '1,180p'

echo '--- heartbeat and stream symbols ---'
rg -n -C 4 'SendStream|SendHeartbeat|emitHeartbeat|Reason|reason|tool.?call|delta|Heartbeat' internal/agent internal 2>/dev/null | head -n 500

Repository: buchenberg/yaah

Length of output: 39292


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- stream.go ---'
sed -n '1,180p' internal/agent/llm/stream.go
printf '%s\n' '--- relevant definitions and call sites ---'
rg -n -C 6 'func .*Send(Stream|Heartbeat)|SendStream|SendHeartbeat|emitHeartbeat|Stream' internal/agent internal 2>/dev/null | head -n 600

Repository: buchenberg/yaah

Length of output: 42601


🏁 Script executed:

#!/bin/bash
set -eu
echo '--- target file ---'
nl -ba internal/agent/llm/stream.go | sed -n '1,180p'
echo '--- symbols ---'
rg -n -C 5 'SendStream|SendHeartbeat|emitHeartbeat|type .*Delta|Reasoning|ToolCall|heartbeat' internal/agent internal 2>/dev/null | head -n 600

Repository: buchenberg/yaah

Length of output: 40463


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target ---'
nl -ba internal/agent/llm/stream.go | sed -n '1,180p'
printf '%s\n' '--- related definitions ---'
rg -n -C 8 'SendStream|SendHeartbeat|emitHeartbeat|Heartbeat|Reasoning|ToolCall|Stream' internal/agent internal 2>/dev/null | head -n 800

Repository: buchenberg/yaah

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -eu
git ls-files | rg '(^|/)(stream|.*agent.*|.*llm.*)' | head -n 200
nl -ba internal/agent/llm/stream.go | sed -n '1,180p'
rg -n -C 8 'SendStream|SendHeartbeat|emitHeartbeat|Heartbeat|Reasoning|ToolCall' . --glob '!vendor/**' --glob '!node_modules/**' | head -n 800

Repository: buchenberg/yaah

Length of output: 50373


🌐 Web query:

"Emit heartbeats independently of content deltas" "SendStream" "SendHeartbeat"

💡 Result:

The concept of emitting heartbeats independently of content deltas—often implemented as SendHeartbeat or similar mechanisms—is a common design pattern used in streaming applications (such as Server-Sent Events, WebSockets, or gRPC streams) to maintain connection liveness and prevent timeouts [1][2][3][4]. Key characteristics of this pattern include: 1. Protocol Liveness: When a stream is idle (e.g., waiting for a slow upstream process like LLM reasoning), intermediaries like proxies, load balancers, or firewalls may close the connection due to inactivity [1][3][5]. Emitting a synthetic heartbeat (e.g., an SSE comment frame like: keepalive or: prefill) at regular intervals ensures the TCP connection remains active without interfering with the actual content stream, as these comments are specified to be ignored by clients [1][3][6][4]. 2. Independent Cadence: Heartbeats are often generated by a separate timer or goroutine that monitors for inactivity [2][7][4]. This ticker is typically gated by the actual transmission of data; if a visible chunk is sent, the heartbeat timer is reset [1][3][6][4]. This ensures heartbeats are emitted only during silent windows and never interleave with or corrupt real data payloads [1][7][4]. 3. Implementation Variations: - Streaming APIs: In systems like SSE or custom streaming protocols, this involves injecting non-functional frames (comments or empty-delta chunks) during idle periods [1][3][5][4]. - Request-Response Heartbeats: In some control-plane protocols, SendHeartbeat may require an explicit acknowledgment (ACK) from the peer to verify the connection is healthy, triggering a state update (e.g., marking a connection unhealthy) if the ACK is not received within a timeout period [8][9][4]. - Consumer Group Management: In messaging services, heartbeat operations inform the server that a consumer instance is still active and participating in a consumer group, preventing it from being removed from the group due to apparent inactivity [10]. Technical implementations often use configuration variables (e.g., HEARTBEAT_INTERVAL) to allow operators to tune or disable this behavior without modifying core streaming logic [3][6][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

echo '--- heartbeat implementation ---'
sed -n '80,112p' internal/jobs/context.go

echo '--- child watchdog ---'
sed -n '160,225p' internal/agent/agent_tools.go

echo '--- focused source verifier ---'
python3 - <<'PY'
from pathlib import Path

source = Path("internal/agent/llm/stream.go").read_text()
required = [
    'emitHeartbeat()',
    'if delta.Content != ""',
    'if delta.ReasoningContent != ""',
    'for _, tc := range delta.ToolCalls',
    'case <-ctx.Done():',
]
missing = [text for text in required if text not in source]
if missing:
    raise SystemExit(f"missing expected source fragments: {missing}")

content_start = source.index('if delta.Content != ""')
heartbeat_pos = source.index('emitHeartbeat()', content_start)
content_end = source.index('\n\t\t\t}', content_start)
if not (content_start < heartbeat_pos < content_end):
    raise SystemExit("heartbeat call is not inside the content-delta branch")

select_start = source.index('\t\tselect {')
select_end = source.index('\n\t\t}', select_start)
select_body = source[select_start:select_end]
if 'time.NewTicker' in select_body or 'ticker.C' in select_body:
    raise SystemExit("unexpected ticker found in stream select")

print("heartbeat call is content-only; stream select has no ticker case")
PY

Repository: buchenberg/yaah

Length of output: 3404


🏁 Script executed:

#!/bin/bash
set -eu
sed -n '80,112p' internal/jobs/context.go
sed -n '160,225p' internal/agent/agent_tools.go
python3 - <<'PY'
from pathlib import Path
source = Path("internal/agent/llm/stream.go").read_text()
assert source.index("emitHeartbeat()") > source.index('if delta.Content != ""')
assert source.index("emitHeartbeat()") < source.index("for _, tc := range delta.ToolCalls")
select = source[source.index("\t\tselect {"):source.index("\n\t\t}", source.index("\t\tselect {"))]
assert "ticker.C" not in select
print("content-only heartbeat and no ticker case")
PY

Repository: buchenberg/yaah

Length of output: 3293


Emit heartbeats independently of stream deltas.

When StuckChildTimeout is active, line 107 resets the watchdog only for delta.Content. Reasoning-only deltas, tool-call-only deltas, and idle SendStream waits can cancel an active sub-agent. Add a ticker case to the stream select with a cadence below the watchdog timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/llm/stream.go` around lines 53 - 59, Update the stream
handling logic in SendStream to emit heartbeats independently of stream deltas
by adding a ticker case to its select loop, using a cadence shorter than
StuckChildTimeout and invoking the existing heartbeat mechanism. Preserve delta
processing while ensuring reasoning-only, tool-call-only, and idle waits reset
the watchdog.

Comment thread internal/tui2/tui2.go
Comment on lines +509 to +512
case ActionScrollUp, ActionPageUp, ActionTop:
t.userScrolled = true
case ActionScrollDown, ActionPageDown, ActionBottom:
t.userScrolled = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Arrow keys inside the input area disable auto-scroll.

globalInputCapture runs before the focused widget sees the event, and these two cases do not check focus. The user presses Up to move the cursor within a multi-line prompt in t.Input, and userScrolled becomes true. Auto-scroll then stays off for the rest of the session until the user presses Down or End. Apply the scroll state only when t.Messages has focus.

🛠️ Proposed fix
 	case ActionScrollUp, ActionPageUp, ActionTop:
-		t.userScrolled = true
+		if t.App.GetFocus() != t.Input {
+			t.userScrolled = true
+		}
 	case ActionScrollDown, ActionPageDown, ActionBottom:
-		t.userScrolled = false
+		if t.App.GetFocus() != t.Input {
+			t.userScrolled = false
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 509 - 512, Update the
ActionScrollUp/ActionPageUp/ActionTop and
ActionScrollDown/ActionPageDown/ActionBottom handling in globalInputCapture to
change userScrolled only when t.Messages has focus. Leave arrow-key events
directed at t.Input from modifying the auto-scroll state.

Comment thread internal/tui2/tui2.go
Comment on lines +583 to +614
entries := []struct {
label string
desc string
cmd command.Cmd
}{
{"help", "Show keybindings and commands", command.CmdHelp},
{"clear", "Clear conversation", command.CmdClear},
{"compact", "Compact context window", command.CmdCompact},
{"stop", "Abort running agent", command.CmdStop},
{"steer", "Inject steering text (requires arg)", command.CmdSteer},
{"model", "Switch model", command.CmdModel},
{"search", "Search messages (requires arg)", command.CmdSearch},
{"verbose", "Toggle verbose mode", command.CmdVerbose},
{"banner", "Toggle banner", command.CmdBanner},
{"top", "Scroll to top", command.CmdTop},
{"bottom", "Scroll to bottom", command.CmdBottom},
{"quit", "Exit yaah", command.CmdQuit},
}

list := tview.NewList().
ShowSecondaryText(true).
SetHighlightFullLine(true).
SetWrapAround(false)

for _, e := range entries {
list.AddItem(e.label, e.desc, 0, func() {
t.Pages.RemovePage(modalName)
t.App.SetFocus(t.Input)
t.focus = focusNormal
t.HandleCommand(e.cmd, "")
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Selecting steer or search in the command list does nothing.

Every entry dispatches t.HandleCommand(e.cmd, ""). HandleCommand requires a non-empty arg for CmdSteer (Line 552) and searchMessages returns immediately on an empty query (Line 802). The two entries labeled "requires arg" are therefore inert. followup is also missing from the list although CmdFollowUp exists.

Prompt for the argument when the entry needs one.

🛠️ Proposed approach
 	entries := []struct {
 		label string
 		desc  string
 		cmd   command.Cmd
+		arg   bool
 	}{
@@
-		{"steer", "Inject steering text (requires arg)", command.CmdSteer},
+		{"steer", "Inject steering text", command.CmdSteer, true},
+		{"followup", "Queue a follow-up prompt", command.CmdFollowUp, true},
@@
-		{"search", "Search messages (requires arg)", command.CmdSearch},
+		{"search", "Search messages", command.CmdSearch, true},
@@
 	for _, e := range entries {
 		list.AddItem(e.label, e.desc, 0, func() {
 			t.Pages.RemovePage(modalName)
 			t.App.SetFocus(t.Input)
 			t.focus = focusNormal
+			if e.arg {
+				t.promptForArg(e.label, e.cmd)
+				return
+			}
 			t.HandleCommand(e.cmd, "")
 		})
 	}

promptForArg can reuse the existing command.Palette input field, which is otherwise unused now.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 583 - 614, Update the command-list
callback around entries and HandleCommand so argument-required commands prompt
for input before dispatching. Use the existing command.Palette input through
promptForArg for CmdSteer and CmdSearch, then pass the entered value to
t.HandleCommand; keep no-argument commands unchanged and add the missing
CmdFollowUp entry.

Comment thread internal/tui2/tui2.go
Comment on lines +780 to +799
func (t *TUI2) toggleBanner() {
t.showBanner = !t.showBanner
if t.showBanner {
t.Root.RemoveItem(t.Header)
t.Root.AddItem(t.Header, t.headerHeight(), 0, false)
} else {
t.Root.RemoveItem(t.Header)
}
}

func (t *TUI2) headerHeight() int {
if !t.showBanner {
return 0
}
_, _, _, h := t.Banner.GetInnerRect()
if h <= 0 {
return 8
}
return h
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

tview Flex ResizeItem AddItem ordering append behavior

💡 Result:

In the tview package, the Flex container manages its items using an internal slice [1][2]. Understanding how AddItem, ResizeItem, and append-related behaviors work is straightforward: 1. AddItem Behavior: The AddItem method appends new items to the end of the Flex container's internal items slice [1][2]. The order in which you call AddItem determines the final display order (from left to right for horizontal layouts, or top to bottom for vertical layouts) [3][4]. Because it uses an append operation, the item is always placed after any existing items [1][2]. 2. ResizeItem Behavior: The ResizeItem method iterates through the entire list of items in the Flex container to find any that match the provided primitive [1][2]. If it finds a match, it updates the fixedSize and proportion values for that item [1][2]. If multiple items in the Flex container share the same primitive, ResizeItem will update all of them to the new size settings [1][2]. 3. Ordering Considerations: The internal order of items is preserved during resizing and removal [1][2]. The RemoveItem method removes all instances of a given primitive and preserves the relative order of the remaining items by shifting the slice accordingly [1][2]. While there have been historical discussions regarding the order of items affecting drawing in other components like Grid [5], Flex maintains a predictable, sequential list based on the order of addition [1][2]. If you need to change the order of items after they have been added, there is no direct "reorder" method; you typically need to use RemoveItem to remove the relevant primitives and then AddItem to add them back in the desired sequence [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- relevant symbols ---'
rg -n -C 6 'func \(t \*TUI2\) (buildUI|toggleBanner|headerHeight)|ResizeItem|AddItem|RemoveItem|showBanner|Banner\.Build' internal/tui2 --glob '*.go'
printf '%s\n' '--- module dependency ---'
rg -n 'rivo/tview|tview' go.mod go.sum internal/tui2 --glob '*.go'
printf '%s\n' '--- file outline and target section ---'
wc -l internal/tui2/tui2.go
sed -n '650,830p' internal/tui2/tui2.go

Repository: buchenberg/yaah

Length of output: 30407


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- buildUI header setup ---'
sed -n '200,292p' internal/tui2/tui2.go
printf '%s\n' '--- banner implementation ---'
cat -n internal/tui2/components/banner/banner.go
printf '%s\n' '--- local tview source locations ---'
go env GOPATH GOMODCACHE 2>/dev/null || true
find "${GOMODCACHE:-/nonexistent}" -path '*github.com/rivo/tview@v0.42.0/flex.go' -o -path '*github.com/rivo/tview@v0.42.0/primitive.go' 2>/dev/null | head
printf '%s\n' '--- references to banner height state ---'
rg -n -C 3 'bannerLines|headerHeight|toggleBanner|Build\(\)' internal/tui2 --glob '*.go'

Repository: buchenberg/yaah

Length of output: 10431


🏁 Script executed:

#!/bin/bash
set -eu
TV=/home/jailuser/go/pkg/mod/github.com/rivo/tview@v0.42.0
printf '%s\n' '--- Flex methods ---'
rg -n -C 12 'func \(f \*Flex\) (AddItem|RemoveItem|ResizeItem|Draw)' "$TV/flex.go"
printf '%s\n' '--- Primitive inner-rect methods ---'
rg -n -C 10 'func \(p \*Box\) (GetInnerRect|SetRect)|func \(.*\) GetInnerRect' "$TV" --glob '*.go' | head -160
printf '%s\n' '--- relevant TUI2 fields ---'
sed -n '35,115p' internal/tui2/tui2.go
printf '%s\n' '--- exact module version metadata ---'
sed -n '1,80p' "$TV/go.mod" 2>/dev/null || true

Repository: buchenberg/yaah

Length of output: 7795


🏁 Script executed:

#!/bin/bash
set -eu
TV=/home/jailuser/go/pkg/mod/github.com/rivo/tview@v0.42.0
printf '%s\n' '--- complete Flex allocation logic ---'
sed -n '131,255p' "$TV/flex.go"
printf '%s\n' '--- Box rectangle state and reset behavior ---'
sed -n '1,145p' "$TV/box.go"
rg -n -C 8 'SetRect\(|innerHeight|innerWidth|SetInnerRect' "$TV" --glob '*.go' | head -220
printf '%s\n' '--- toggle call context ---'
sed -n '430,585p' internal/tui2/tui2.go

Repository: buchenberg/yaah

Length of output: 32878


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- banner generator ---'
fd -i -t f . internal | rg '/banner|banner'
rg -n -C 8 'func GeneratePlain|GeneratePlain|figlet|tagline|yaah' internal/banner internal/tui2/components/banner --glob '*.go' 2>/dev/null
printf '%s\n' '--- banner-related tests ---'
rg -n -C 5 'toggleBanner|headerHeight|bannerLines|GeneratePlain|CmdBanner' . --glob '*_test.go' --glob '*.go'
printf '%s\n' '--- current status and diff summary ---'
git diff --stat
git status --short

Repository: buchenberg/yaah

Length of output: 26477


Keep the header in the original layout position.

tview.Flex.AddItem appends the header after StatusBar when the banner is shown again. Use ResizeItem instead of removing and re-adding it. Store the banner.Build() line count and use it in headerHeight; GetInnerRect becomes zero after the header collapses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 780 - 799, Update toggleBanner to keep
Header in its original Root flex position by using ResizeItem rather than
removing and re-adding it. Store the line count returned by banner.Build() and
have headerHeight use that stored value, since Banner.GetInnerRect() is zero
after collapse.

Comment thread internal/tui2/tui2.go
Comment on lines +801 to +814
func (t *TUI2) searchMessages(query string) {
if query == "" {
return
}
text := t.Messages.GetText(true)
idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
if idx >= 0 {
line := strings.Count(text[:idx], "\n")
t.Messages.ScrollTo(line, 0)
t.SetEphemeral(fmt.Sprintf("Found at line %d", line+1))
} else {
t.SetEphemeral("No matches found")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The reported line number can point at the wrong row.

Two problems:

  1. idx is a byte offset into strings.ToLower(text), but Line 808 slices the original text with it. For most input the lengths match. For a few Unicode characters ToLower changes the byte length, and the slice then lands mid-rune and reports a wrong line.
  2. t.Messages is built with SetWrap(true). ScrollTo takes a row in the wrapped display, while strings.Count counts logical newlines. On long lines the view scrolls short of the match.

Lower the text once and index that single value to fix the first problem.

🛠️ Proposed fix
 	text := t.Messages.GetText(true)
-	idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
+	lower := strings.ToLower(text)
+	idx := strings.Index(lower, strings.ToLower(query))
 	if idx >= 0 {
-		line := strings.Count(text[:idx], "\n")
+		line := strings.Count(lower[:idx], "\n")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 801 - 814, Update TUI2.searchMessages to
derive the match index and newline count from the same lowercased text value,
avoiding slicing the original text with an offset from a transformed string.
Also convert the logical line containing the match into the wrapped display row
expected by t.Messages.ScrollTo, accounting for SetWrap(true) and long lines.

Comment thread internal/tui2/tui2.go
Comment on lines +816 to +826
func (t *TUI2) SetEphemeral(msg string) {
t.ephemeralMsg = msg
t.renderInfoPane()
go func() {
time.Sleep(3 * time.Second)
t.App.QueueUpdateDraw(func() {
t.ephemeralMsg = ""
t.renderInfoPane()
})
}()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Overlapping ephemeral messages clear each other early.

Each call starts an independent 3-second goroutine. If a second message arrives one second after the first, the first goroutine still fires at t=3s and clears the newer message after only two seconds. Guard the clear with a sequence number.

🛠️ Proposed fix
 func (t *TUI2) SetEphemeral(msg string) {
 	t.ephemeralMsg = msg
+	t.ephemeralSeq++
+	seq := t.ephemeralSeq
 	t.renderInfoPane()
 	go func() {
 		time.Sleep(3 * time.Second)
 		t.App.QueueUpdateDraw(func() {
+			if seq != t.ephemeralSeq {
+				return
+			}
 			t.ephemeralMsg = ""
 			t.renderInfoPane()
 		})
 	}()
 }

Add ephemeralSeq int next to ephemeralMsg in the TUI2 struct.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui2/tui2.go` around lines 816 - 826, Update TUI2.SetEphemeral to
prevent older delayed clears from removing newer messages: add an ephemeralSeq
field beside ephemeralMsg, increment it on each call, capture the current
sequence in the goroutine, and clear/render only when the captured sequence
still matches the latest value.

@buchenberg
buchenberg merged commit 1d9c8aa into main Aug 8, 2026
5 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Aug 9, 2026
@buchenberg
buchenberg deleted the feat/tui2-phase3-parity branch August 10, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant