Skip to content

fix(tui): preserve input panel during streamed turns#153

Merged
Desperado merged 3 commits into
mainfrom
agent/preserve-stream-input-boundary
Jul 18, 2026
Merged

fix(tui): preserve input panel during streamed turns#153
Desperado merged 3 commits into
mainfrom
agent/preserve-stream-input-boundary

Conversation

@Desperado

@Desperado Desperado commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What changed

  • replace the transient post-submit input view with a Bubble Tea-owned viewport that persists for the full agent turn
  • keep streamed output, tool results, plans, CLI diagnostics, screenshots, spinners, and test progress above a protected input/status panel
  • preserve type-to-queue, Enter-to-interrupt, Ctrl+C cancellation, partial drafts, word navigation, and terminal scrollback
  • route screenshot and clipboard-image turns through the same viewport without changing their multimodal backend behavior
  • add focused viewport regression coverage

Root cause

Streaming and tool output wrote directly to stdout after the input component exited. Those independent cursor writers could overwrite or displace the bordered input/status region as soon as streaming began.

Impact

The input panel now remains visible and editable throughout long agent turns. Live redraw and retained scrollback buffers are bounded independently, wrapping is cached between unchanged frames, and retained output is restored to normal terminal scrollback when the turn completes.

Validation

  • go test ./...
  • go test -race ./internal/tui ./internal/agent ./internal/repl
  • go vet ./...
  • git diff --check

@sigilix

sigilix Bot commented Jul 18, 2026

Copy link
Copy Markdown

Sigilix review resumed

This PR is ready for review. The earlier draft-skip notice has been superseded by the normal review run.

Current commit: 3f8d476

@qualitymaxapp

qualitymaxapp Bot commented Jul 18, 2026

Copy link
Copy Markdown

✅ QualityMax Pipeline

Gate Result
🔍 AI Review ✅ Clean
🧪 Repo Tests ✅ 489/489 passed (go)
🤖 AI Tests ✅ 47/51 passed

Powered by QualityMax — AI-Powered Test Automation

@Desperado
Desperado marked this pull request as ready for review July 18, 2026 07:39
@sigilix

sigilix Bot commented Jul 18, 2026

Copy link
Copy Markdown

Sigilix Overview

Effort: 5/5 (needs deep review)

Quality gates

  • ✅ PR title follows convention
  • ✅ PR description is complete
  • ℹ️ PR is linked to an issue — No Closes #N / Closes SIG-N keyword found in PR body or commit messages.

Summary — latest push

Replaces the transient post-submit input view with a persistent Bubble Tea viewport that keeps the input/status panel visible and editable during streamed agent turns, preventing independent cursor writers from overwriting the bordered region. Agent stderr, tool output, and test progress are now routed through the viewport's activity/output channels, and retained scrollback is re-emitted to the terminal when the turn completes. The specialist review flagged a P0 race where a late subprocess write after detach can corrupt the terminal, alongside P1 concerns around unbounded string copies during output bounding and a missing nil-guard on the cache pointer.

Important files

File Score Notes Next step
internal/tui/turn_viewport.go 5/5 New 373-line Bubble Tea model that owns the terminal during agent turns, managing output bounding, wrap caching, input editing, and scrollback restoration. Guard m.cache with a nil check in visibleOutput before dereferencing m.cache.lines, and consider replacing the string-copy bounding in appendOutput with a ring buffer or pooled buffers to avoid O(n) allocations on every truncation.
internal/tui/terminal.go 5/5 Reroutes all fmt.Print/Printf/Println calls through Terminal.emit/printf so output is sent to the active turn viewport instead of stdout, and adds thread-safe attach/detach for the turn program. Audit all remaining direct fmt.Print calls in this file and the broader package to ensure no output can escape below the viewport boundary.
internal/repl/repl.go 5/5 Wraps the agent turn execution in RunTurnViewport, unifies screenshot/paste image handling into the normal turn flow, and removes the old queue reader in favor of the viewport's type-to-queue model. Verify that the removed queue reader logic does not break the ability to queue multiple prompts during a long turn, and that the runWithCLI flag correctly preserves the multimodal backend path for screenshots.
internal/tui/turn_viewport_test.go 4/5 Comprehensive regression tests covering output boundary preservation, activity replacement, input editing, draft recovery, viewport height clipping, wrap caching, and output bounding with ANSI/UTF-8 edge cases. Add a concurrent-safety test that sends turnOutputMsg while RunTurnViewport's goroutine is closing, and a test for the late-write race where output arrives after detachTurnProgram.
internal/agent/tools.go 4/5 Plumbs *tui.Terminal through executeTool and runTestWithProgress so test progress can route through the viewport's activity line instead of writing directly to stdout. Verify that all callers of ExecuteTool in non-TUI paths pass a nil terminal and that the nil path correctly falls back to the legacy cursor-rewriting animation without panicking.

Sequence diagram

sequenceDiagram
    participant User
    participant Viewport as turnViewportModel
    participant Terminal
    participant Agent as Agent Subprocess
    User->>Viewport: KeyMsg (type / Enter)
    Viewport->>Terminal: cancelFn() on Enter/Ctrl+C
    Agent->>Terminal: stderr (via term.Stderr())
    Agent->>Terminal: stdout stream
    Terminal->>Viewport: turnOutputMsg / turnActivityMsg
    Viewport->>Viewport: appendOutput + bound + wrap
    Agent->>Viewport: turnDoneMsg
    Viewport->>Terminal: emit retained scrollback
    Viewport->>User: Quit (return TurnInputResult)
Loading

Confidence: 2/5

A P0 race condition where late subprocess output can corrupt the terminal after detach, combined with O(n) string copies on every output bounding cycle, makes this unsafe to merge without addressing the concurrency and performance regressions.

  • Fix the P0 race in RunTurnViewport: after detachTurnProgram, a late write from the subprocess can escape below the former input panel — add synchronization so the goroutine is fully drained before detach, or discard output after detach.
  • Guard m.cache nil dereference in visibleOutput (line ~275) — the cache pointer can be nil if visibleOutput is called before any Update, causing a panic.
  • Replace the string-copy bounding in appendOutput with a more efficient strategy — currently every truncation allocates a new strings.Builder and copies up to keptTurnOutput (3 MB), which will cause GC pressure on verbose turns.
  • Verify that all non-TUI callers of ExecuteTool pass nil terminal and that the nil path in runTestWithProgress correctly falls back to the legacy animation without nil pointer dereferences.
  • Confirm that renderInputBox in input.go handles multi-line submitted text and wide Unicode without breaking the bordered panel width.

Suggested labels: bug perf


Posted · f66eea6 · 11 findings — View review
Proof: 11 model-only
runner-verified = CI receipt · reproduced = sandbox observed diff · grounded = deterministic detector/worker-token · model-only = model judgment only
Dismiss @sigilix dismiss <reason> (not-a-bug | bad-anchor | already-covered | too-minor | wrong-context) · Re-run /sigilix review · Review #2
Sigilix · 2 of 50 reviews used in past 5h

@sigilix sigilix Bot added the enhancement New feature or request label Jul 18, 2026

@sigilix sigilix 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.

📊 Reviewed 13 of 14 changed files across this PR so far — the remaining 1 was below the priority cutoff for this very large PR. Split into smaller PRs to cover them.

1 finding outside the diff
File Scope Finding
internal/tui/terminal.go:175 file-scope Race condition on turnProgram field between attach/detach and activeTurnProgram

Comment thread internal/tui/terminal.go

func newSpinner(t *Terminal) *spinner {
s := &spinner{stop: make(chan struct{}), term: t}
if p := t.activeTurnProgram(); p != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 LOGICGROUNDED Nil-pointer dereference in spinner when Terminal is nil

newSpinner calls t.activeTurnProgram() without checking if t is nil. The Terminal receiver on activeTurnProgram is a pointer receiver, so calling it on a nil *Terminal will panic. While newSpinner is only called from StartThinking which has a nil guard, the newSpinner function itself is exported and could be called directly. The fix is to add a nil check at the top of newSpinner before accessing t.activeTurnProgram().

More Info
  • Threat model: A nil *Terminal passed to newSpinner causes a runtime panic, crashing the application.
  • Specific code citations: newSpinner at terminal.go:117 calls t.activeTurnProgram() on line 118 without a nil check on t.
  • Existing protections: StartThinking (the only current caller) checks t == nil before calling newSpinner, but the function itself is unprotected.
  • Proposed mitigation: Add if t == nil { return &spinner{stop: make(chan struct{})} } at the top of newSpinner before any field access.
  • Alternative mitigations considered: Make newSpinner unexported, but that doesn't prevent future internal misuse.
  • Severity calibration: Score 4 because the only current caller is safe, but the function is exported and a future caller could trigger a panic.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/terminal.go
Line: 118

Comment:
**Nil-pointer dereference in spinner when Terminal is nil**

`newSpinner` calls `t.activeTurnProgram()` without checking if `t` is nil. The `Terminal` receiver on `activeTurnProgram` is a pointer receiver, so calling it on a nil `*Terminal` will panic. While `newSpinner` is only called from `StartThinking` which has a nil guard, the `newSpinner` function itself is exported and could be called directly. The fix is to add a nil check at the top of `newSpinner` before accessing `t.activeTurnProgram()`.

Threat model:
A nil `*Terminal` passed to `newSpinner` causes a runtime panic, crashing the application.

Specific code citations:
`newSpinner` at `terminal.go:117` calls `t.activeTurnProgram()` on line 118 without a nil check on `t`.

Existing protections:
`StartThinking` (the only current caller) checks `t == nil` before calling `newSpinner`, but the function itself is unprotected.

Proposed mitigation:
Add `if t == nil { return &spinner{stop: make(chan struct{})} }` at the top of `newSpinner` before any field access.

Alternative mitigations considered:
Make `newSpinner` unexported, but that doesn't prevent future internal misuse.

Severity calibration:
Score 4 because the only current caller is safe, but the function is exported and a future caller could trigger a panic.

How can I resolve this? If you propose a fix, please make it concise.

if ok && final.output != nil {
// Bubble Tea only owns the live viewport. Re-emit the complete turn once
// after it exits so normal terminal scrollback retains the full response.
term.emit(final.output.String())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 LOGICGROUNDED Error from p.Run() is silently ignored, masking Bubble Tea failures

RunTurnViewport calls p.Run() and assigns the error to err, but the error is only checked at the very end of the function after final.output.String() is called. If p.Run() returns an error, final will be the zero-value turnViewportModel, and final.output will be nil. The code then calls final.output.String() on a nil *strings.Builder, causing a panic. The error check should happen before accessing final.output.

More Info
  • Threat model: A Bubble Tea initialization or runtime error causes p.Run() to return a non-nil error, leading to a nil-pointer dereference on final.output.String().
  • Specific code citations: RunTurnViewport at turn_viewport.go:268 calls p.Run() and stores the error. Line 275 calls final.output.String() before checking err on line 280.
  • Existing protections: None — the error is checked after the nil-prone access.
  • Proposed mitigation: Move the error check before final.output.String(): if err != nil, return early without accessing final.output.
  • Alternative mitigations considered: Initialize final.output to an empty builder before p.Run(), but that still masks the error.
  • Severity calibration: Score 4 because Bubble Tea errors are rare but the nil dereference is guaranteed when they occur.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 275

Comment:
**Error from `p.Run()` is silently ignored, masking Bubble Tea failures**

`RunTurnViewport` calls `p.Run()` and assigns the error to `err`, but the error is only checked at the very end of the function after `final.output.String()` is called. If `p.Run()` returns an error, `final` will be the zero-value `turnViewportModel`, and `final.output` will be nil. The code then calls `final.output.String()` on a nil `*strings.Builder`, causing a panic. The error check should happen before accessing `final.output`.

Threat model:
A Bubble Tea initialization or runtime error causes `p.Run()` to return a non-nil error, leading to a nil-pointer dereference on `final.output.String()`.

Specific code citations:
`RunTurnViewport` at `turn_viewport.go:268` calls `p.Run()` and stores the error. Line 275 calls `final.output.String()` before checking `err` on line 280.

Existing protections:
None — the error is checked after the nil-prone access.

Proposed mitigation:
Move the error check before `final.output.String()`: if `err != nil`, return early without accessing `final.output`.

Alternative mitigations considered:
Initialize `final.output` to an empty builder before `p.Run()`, but that still masks the error.

Severity calibration:
Score 4 because Bubble Tea errors are rare but the nil dereference is guaranteed when they occur.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/tui/turn_viewport.go Outdated
}
// Bubble Tea truncates over-wide physical lines. Wrap them first so a
// streamed paragraph remains readable while the persistent panel is active.
out = ansi.Wrap(out, width, "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 LOGICGROUNDED visibleOutput panics if m.output is nil

visibleOutput calls m.output.String() without checking if m.output is nil. While newTurnViewportModel initializes m.output to a new strings.Builder, the model is also constructed via the zero-value when p.Run() returns (e.g., on error). If View() is called on a zero-value model, this will panic. The fix is to add a nil check at the top of visibleOutput.

More Info
  • Threat model: A zero-value turnViewportModel (e.g., from a Bubble Tea error) causes a nil-pointer dereference in View().
  • Specific code citations: visibleOutput at turn_viewport.go:237 calls m.output.String() without a nil check.
  • Existing protections: newTurnViewportModel initializes m.output, but the zero-value path (line 280) does not.
  • Proposed mitigation: Add if m.output == nil { return "" } at the top of visibleOutput.
  • Alternative mitigations considered: Initialize m.output in the zero-value return path of RunTurnViewport.
  • Severity calibration: Score 3 because the zero-value path is only reached on error, but the panic is guaranteed in that case.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 237

Comment:
**`visibleOutput` panics if `m.output` is nil**

`visibleOutput` calls `m.output.String()` without checking if `m.output` is nil. While `newTurnViewportModel` initializes `m.output` to a new `strings.Builder`, the model is also constructed via the zero-value when `p.Run()` returns (e.g., on error). If `View()` is called on a zero-value model, this will panic. The fix is to add a nil check at the top of `visibleOutput`.

Threat model:
A zero-value `turnViewportModel` (e.g., from a Bubble Tea error) causes a nil-pointer dereference in `View()`.

Specific code citations:
`visibleOutput` at `turn_viewport.go:237` calls `m.output.String()` without a nil check.

Existing protections:
`newTurnViewportModel` initializes `m.output`, but the zero-value path (line 280) does not.

Proposed mitigation:
Add `if m.output == nil { return "" }` at the top of `visibleOutput`.

Alternative mitigations considered:
Initialize `m.output` in the zero-value return path of `RunTurnViewport`.

Severity calibration:
Score 3 because the zero-value path is only reached on error, but the panic is guaranteed in that case.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/tui/turn_viewport.go Outdated
if m.output.Len()-m.liveFrom > maxLiveOutput {
out := m.output.String()
target := len(out) - maxLiveOutput
if newline := strings.IndexByte(out[target:], '\n'); newline >= 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 LOGICGROUNDED liveFrom can advance past the end of the output string, causing a slice bounds panic

In the turnOutputMsg handler, m.liveFrom is advanced to target + newline + 1 where target = len(out) - maxLiveOutput. If maxLiveOutput is larger than len(out), target is negative, and out[target:] slices from the end of the string. The strings.IndexByte call on a negative-start slice is safe in Go (it treats negative indices as 0 for slicing), but the resulting m.liveFrom could be set to a value greater than len(out), causing a panic on the next m.output.String()[m.liveFrom:] call in visibleOutput. The fix is to clamp target to 0 before using it.

More Info
  • Threat model: When the total output is less than maxLiveOutput, target becomes negative, and m.liveFrom can be set to an out-of-bounds index, causing a slice bounds panic.
  • Specific code citations: turnOutputMsg handler at turn_viewport.go:75 computes target = len(out) - maxLiveOutput without clamping to 0.
  • Existing protections: The if m.output.Len()-m.liveFrom > maxLiveOutput guard prevents this when the live window is already large, but not on the first few messages when total output is small.
  • Proposed mitigation: Clamp target to max(target, 0) before using it.
  • Alternative mitigations considered: Use max(0, len(out)-maxLiveOutput) directly.
  • Severity calibration: Score 3 because the condition requires specific output sizes and the panic is edge-case but real.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 75

Comment:
**`liveFrom` can advance past the end of the output string, causing a slice bounds panic**

In the `turnOutputMsg` handler, `m.liveFrom` is advanced to `target + newline + 1` where `target = len(out) - maxLiveOutput`. If `maxLiveOutput` is larger than `len(out)`, `target` is negative, and `out[target:]` slices from the end of the string. The `strings.IndexByte` call on a negative-start slice is safe in Go (it treats negative indices as 0 for slicing), but the resulting `m.liveFrom` could be set to a value greater than `len(out)`, causing a panic on the next `m.output.String()[m.liveFrom:]` call in `visibleOutput`. The fix is to clamp `target` to 0 before using it.

Threat model:
When the total output is less than `maxLiveOutput`, `target` becomes negative, and `m.liveFrom` can be set to an out-of-bounds index, causing a slice bounds panic.

Specific code citations:
`turnOutputMsg` handler at `turn_viewport.go:75` computes `target = len(out) - maxLiveOutput` without clamping to 0.

Existing protections:
The `if m.output.Len()-m.liveFrom > maxLiveOutput` guard prevents this when the live window is already large, but not on the first few messages when total output is small.

Proposed mitigation:
Clamp `target` to `max(target, 0)` before using it.

Alternative mitigations considered:
Use `max(0, len(out)-maxLiveOutput)` directly.

Severity calibration:
Score 3 because the condition requires specific output sizes and the panic is edge-case but real.

How can I resolve this? If you propose a fix, please make it concise.

return m, nil
}

func (m turnViewportModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.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.

P2 LOGICGROUNDED turnDoneMsg handler overwrites m.result.Text even when already set by Enter

When the user presses Enter to queue-and-interrupt, updateKey sets m.result.Text and m.result.Canceled = true, then returns tea.Sequence(cancelFn, tea.Quit). The cancelFn eventually causes the backend run() to return, which sends turnDoneMsg. The turnDoneMsg handler then overwrites m.result.Text with strings.TrimSpace(string(m.text)), which is the text at the time the backend finished — potentially empty if the user cleared the input after pressing Enter. This means the queued text could be lost. The fix is to only set m.result.Text in turnDoneMsg if it hasn't already been set by a user action.

More Info
  • Threat model: User presses Enter to queue text and interrupt, but the backend finishes before Bubble Tea processes the quit, causing turnDoneMsg to overwrite the queued text with an empty string.
  • Specific code citations: updateKey at turn_viewport.go:108 sets m.result.Text. turnDoneMsg handler at turn_viewport.go:100 unconditionally overwrites it.
  • Existing protections: None — the handlers don't coordinate on m.result.Text.
  • Proposed mitigation: In turnDoneMsg, only set m.result.Text if m.result.Text == "".
  • Alternative mitigations considered: Use a separate flag to track whether the user already set the result.
  • Severity calibration: Score 3 because the race window is small (between Enter and backend shutdown) but the data loss is real.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 102

Comment:
**`turnDoneMsg` handler overwrites `m.result.Text` even when already set by Enter**

When the user presses Enter to queue-and-interrupt, `updateKey` sets `m.result.Text` and `m.result.Canceled = true`, then returns `tea.Sequence(cancelFn, tea.Quit)`. The `cancelFn` eventually causes the backend `run()` to return, which sends `turnDoneMsg`. The `turnDoneMsg` handler then overwrites `m.result.Text` with `strings.TrimSpace(string(m.text))`, which is the text at the time the backend finished — potentially empty if the user cleared the input after pressing Enter. This means the queued text could be lost. The fix is to only set `m.result.Text` in `turnDoneMsg` if it hasn't already been set by a user action.

Threat model:
User presses Enter to queue text and interrupt, but the backend finishes before Bubble Tea processes the quit, causing `turnDoneMsg` to overwrite the queued text with an empty string.

Specific code citations:
`updateKey` at `turn_viewport.go:108` sets `m.result.Text`. `turnDoneMsg` handler at `turn_viewport.go:100` unconditionally overwrites it.

Existing protections:
None — the handlers don't coordinate on `m.result.Text`.

Proposed mitigation:
In `turnDoneMsg`, only set `m.result.Text` if `m.result.Text == ""`.

Alternative mitigations considered:
Use a separate flag to track whether the user already set the result.

Severity calibration:
Score 3 because the race window is small (between Enter and backend shutdown) but the data loss is real.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/tui/terminal.go
Comment on lines 115 to +118

func newSpinner(t *Terminal) *spinner {
s := &spinner{stop: make(chan struct{}), term: t}
if p := t.activeTurnProgram(); p != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 LOGICGROUNDED newSpinner sends turnThinkingMsg(true) but spinner.Stop() sends turnThinkingMsg(false) — the spinner goroutine still runs

Low-confidence finding — expand to read

When a turn viewport is active, newSpinner sends turnThinkingMsg(true) to the Bubble Tea program and returns early WITHOUT starting the spinner goroutine. However, spinner.Stop() still calls s.done.Do(...) which sends turnThinkingMsg(false) and returns early. The sync.Once ensures this only happens once, but the spinner struct's wg and stop channel are never used in this path. This is not a bug per se, but it means the spinner object is in a half-initialized state (no goroutine, but wg is non-zero). If any code calls s.wg.Wait() on this spinner, it would return immediately (since no goroutine was started), which is correct but confusing. Consider making the viewport path not create a spinner at all.

More Info
  • Threat model: Confusing half-initialized state; no runtime failure but maintenance hazard.
  • Specific code citations: newSpinner at terminal.go:118 returns early without starting the goroutine. spinner.Stop() at terminal.go:152 still uses s.done.Do.
  • Existing protections: sync.Once prevents double-close of the channel.
  • Proposed mitigation: Return nil from newSpinner when a viewport is active, and handle nil in StopThinking.
  • Alternative mitigations considered: Document the half-initialized state.
  • Severity calibration: Score 2 because it's a maintainability concern, not a runtime bug.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/terminal.go
Line: 115-118

Comment:
**`newSpinner` sends `turnThinkingMsg(true)` but `spinner.Stop()` sends `turnThinkingMsg(false)` — the spinner goroutine still runs**

When a turn viewport is active, `newSpinner` sends `turnThinkingMsg(true)` to the Bubble Tea program and returns early WITHOUT starting the spinner goroutine. However, `spinner.Stop()` still calls `s.done.Do(...)` which sends `turnThinkingMsg(false)` and returns early. The `sync.Once` ensures this only happens once, but the `spinner` struct's `wg` and `stop` channel are never used in this path. This is not a bug per se, but it means the `spinner` object is in a half-initialized state (no goroutine, but `wg` is non-zero). If any code calls `s.wg.Wait()` on this spinner, it would return immediately (since no goroutine was started), which is correct but confusing. Consider making the viewport path not create a spinner at all.

Threat model:
Confusing half-initialized state; no runtime failure but maintenance hazard.

Specific code citations:
`newSpinner` at `terminal.go:118` returns early without starting the goroutine. `spinner.Stop()` at `terminal.go:152` still uses `s.done.Do`.

Existing protections:
`sync.Once` prevents double-close of the channel.

Proposed mitigation:
Return nil from `newSpinner` when a viewport is active, and handle nil in `StopThinking`.

Alternative mitigations considered:
Document the half-initialized state.

Severity calibration:
Score 2 because it's a maintainability concern, not a runtime bug.

How can I resolve this? If you propose a fix, please make it concise.

if w <= 0 {
w = 80
}
input := inputModel{prompt: m.prompt, width: w, status: m.status}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 LOGICGROUNDED View() creates a new inputModel on every frame, discarding any internal state

Low-confidence finding — expand to read

turnViewportModel.View() creates a new inputModel literal on every call: input := inputModel{prompt: m.prompt, width: w, status: m.status}. If inputModel ever gains internal state (e.g., animation counters, caching), this pattern will reset it on every frame. Currently inputModel is stateless in its renderInputBox and renderStatus methods, so this is safe, but it's a fragile pattern. Consider storing the inputModel as a field on turnViewportModel and updating its fields when they change.

More Info
  • Threat model: Future changes to inputModel that add state will be silently broken by the per-frame reconstruction.
  • Specific code citations: turnViewportModel.View() at turn_viewport.go:205 creates a new inputModel literal.
  • Existing protections: inputModel is currently stateless in its rendering methods.
  • Proposed mitigation: Store inputModel as a field on turnViewportModel and update it on relevant messages.
  • Alternative mitigations considered: Add a comment warning about the stateless assumption.
  • Severity calibration: Score 2 because it's a forward-compatibility concern with no current bug.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 203

Comment:
**`View()` creates a new `inputModel` on every frame, discarding any internal state**

`turnViewportModel.View()` creates a new `inputModel` literal on every call: `input := inputModel{prompt: m.prompt, width: w, status: m.status}`. If `inputModel` ever gains internal state (e.g., animation counters, caching), this pattern will reset it on every frame. Currently `inputModel` is stateless in its `renderInputBox` and `renderStatus` methods, so this is safe, but it's a fragile pattern. Consider storing the `inputModel` as a field on `turnViewportModel` and updating its fields when they change.

Threat model:
Future changes to `inputModel` that add state will be silently broken by the per-frame reconstruction.

Specific code citations:
`turnViewportModel.View()` at `turn_viewport.go:205` creates a new `inputModel` literal.

Existing protections:
`inputModel` is currently stateless in its rendering methods.

Proposed mitigation:
Store `inputModel` as a field on `turnViewportModel` and update it on relevant messages.

Alternative mitigations considered:
Add a comment warning about the stateless assumption.

Severity calibration:
Score 2 because it's a forward-compatibility concern with no current bug.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/tui/turn_viewport.go Outdated
}
// Bubble Tea truncates over-wide physical lines. Wrap them first so a
// streamed paragraph remains readable while the persistent panel is active.
out = ansi.Wrap(out, width, "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 PERFGROUNDED visibleOutput calls ansi.Wrap on every View() call, potentially causing performance issues with large output

Low-confidence finding — expand to read

visibleOutput calls ansi.Wrap(out, width, "") on every frame render. For large accumulated output (up to 64KB live window), this re-wraps the entire visible buffer on every 100ms tick. The wrapping result is not cached, so it's recomputed even when the output hasn't changed. Consider caching the wrapped output and only recomputing when m.output or m.width changes.

More Info
  • Threat model: High CPU usage during long agent turns with large output, potentially causing visible lag in the terminal.
  • Specific code citations: visibleOutput at turn_viewport.go:242 calls ansi.Wrap unconditionally.
  • Existing protections: The live window is capped at 64KB, limiting the worst-case cost.
  • Proposed mitigation: Cache the wrapped output and invalidate on output/width changes.
  • Alternative mitigations considered: Accept the cost; 64KB wrapping is fast on modern hardware.
  • Severity calibration: Score 2 because the 64KB cap limits the impact, but it's still unnecessary work on every frame.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 237

Comment:
**`visibleOutput` calls `ansi.Wrap` on every `View()` call, potentially causing performance issues with large output**

`visibleOutput` calls `ansi.Wrap(out, width, "")` on every frame render. For large accumulated output (up to 64KB live window), this re-wraps the entire visible buffer on every 100ms tick. The wrapping result is not cached, so it's recomputed even when the output hasn't changed. Consider caching the wrapped output and only recomputing when `m.output` or `m.width` changes.

Threat model:
High CPU usage during long agent turns with large output, potentially causing visible lag in the terminal.

Specific code citations:
`visibleOutput` at `turn_viewport.go:242` calls `ansi.Wrap` unconditionally.

Existing protections:
The live window is capped at 64KB, limiting the worst-case cost.

Proposed mitigation:
Cache the wrapped output and invalidate on output/width changes.

Alternative mitigations considered:
Accept the cost; 64KB wrapping is fast on modern hardware.

Severity calibration:
Score 2 because the 64KB cap limits the impact, but it's still unnecessary work on every frame.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread internal/tui/terminal.go

func (t *Terminal) emit(text string) {
if p := t.activeTurnProgram(); p != nil {
p.Send(turnOutputMsg(text))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 LOGICGROUNDED emit method sends turnOutputMsg to a potentially nil program without nil check

Low-confidence finding — expand to read

emit calls p.Send(turnOutputMsg(text)) on the pointer returned by activeTurnProgram(). If activeTurnProgram returns a non-nil pointer that has already been shut down (Bubble Tea program exited but pointer not yet cleared), p.Send() will panic or deadlock. Bubble Tea's Send method is not safe to call after the program has exited. The fix is to check if the program is still running before sending, or to use a channel-based approach.

More Info
  • Threat model: A send to an exited Bubble Tea program causes a panic or deadlock.
  • Specific code citations: emit at terminal.go:194 calls p.Send() without checking if the program is still running.
  • Existing protections: detachTurnProgram sets turnProgram = nil, but there's a race window between program exit and detach.
  • Proposed mitigation: Use a channel or a done flag to prevent sends to an exited program.
  • Alternative mitigations considered: Accept the race; Bubble Tea's Send may be safe after exit in practice.
  • Severity calibration: Score 2 because the race window is small and Bubble Tea may handle this gracefully.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/terminal.go
Line: 198

Comment:
**`emit` method sends `turnOutputMsg` to a potentially nil program without nil check**

`emit` calls `p.Send(turnOutputMsg(text))` on the pointer returned by `activeTurnProgram()`. If `activeTurnProgram` returns a non-nil pointer that has already been shut down (Bubble Tea program exited but pointer not yet cleared), `p.Send()` will panic or deadlock. Bubble Tea's `Send` method is not safe to call after the program has exited. The fix is to check if the program is still running before sending, or to use a channel-based approach.

Threat model:
A send to an exited Bubble Tea program causes a panic or deadlock.

Specific code citations:
`emit` at `terminal.go:194` calls `p.Send()` without checking if the program is still running.

Existing protections:
`detachTurnProgram` sets `turnProgram = nil`, but there's a race window between program exit and detach.

Proposed mitigation:
Use a channel or a done flag to prevent sends to an exited program.

Alternative mitigations considered:
Accept the race; Bubble Tea's `Send` may be safe after exit in practice.

Severity calibration:
Score 2 because the race window is small and Bubble Tea may handle this gracefully.

How can I resolve this? If you propose a fix, please make it concise.

if text == "" {
return m, nil
}
m.result.Text = text

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 LOGICGROUNDED Enter key handler calls m.cancelFn() via tea.Sequence but doesn't check if it's nil

In updateKey, the Enter handler calls m.cancelFn() inside a tea.Sequence without checking if m.cancelFn is nil. While newTurnViewportModel always sets cancelFn from the parameter (which could be nil), the zero-value model has cancelFn: nil. If the Enter key is processed on a zero-value model, this will panic. The Ctrl+C handler has the same issue. The fix is to add a nil check before calling m.cancelFn.

More Info
  • Threat model: Nil function call causes panic if Enter is pressed on a zero-value model.
  • Specific code citations: updateKey at turn_viewport.go:108 calls m.cancelFn() without nil check.
  • Existing protections: newTurnViewportModel receives cancelFn as a parameter, which may be nil.
  • Proposed mitigation: Add if m.cancelFn != nil { m.cancelFn() } in both Enter and Ctrl+C handlers.
  • Alternative mitigations considered: Ensure cancelFn is never nil by using a no-op function.
  • Severity calibration: Score 2 because the zero-value path is unlikely in practice but the nil dereference is real.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 109

Comment:
**Enter key handler calls `m.cancelFn()` via `tea.Sequence` but doesn't check if it's nil**

In `updateKey`, the Enter handler calls `m.cancelFn()` inside a `tea.Sequence` without checking if `m.cancelFn` is nil. While `newTurnViewportModel` always sets `cancelFn` from the parameter (which could be nil), the zero-value model has `cancelFn: nil`. If the Enter key is processed on a zero-value model, this will panic. The Ctrl+C handler has the same issue. The fix is to add a nil check before calling `m.cancelFn`.

Threat model:
Nil function call causes panic if Enter is pressed on a zero-value model.

Specific code citations:
`updateKey` at `turn_viewport.go:108` calls `m.cancelFn()` without nil check.

Existing protections:
`newTurnViewportModel` receives `cancelFn` as a parameter, which may be nil.

Proposed mitigation:
Add `if m.cancelFn != nil { m.cancelFn() }` in both Enter and Ctrl+C handlers.

Alternative mitigations considered:
Ensure `cancelFn` is never nil by using a no-op function.

Severity calibration:
Score 2 because the zero-value path is unlikely in practice but the nil dereference is real.

How can I resolve this? If you propose a fix, please make it concise.

@Desperado Desperado changed the title Keep the input panel persistent during streamed turns fix(tui): preserve input panel during streamed turns Jul 18, 2026

@sigilix sigilix 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.

1 finding outside the diff
File Scope Finding
internal/tui/turn_viewport.go file-scope safeOutputSuffix fallback path lacks test coverage for ANSI escape sequence splitting

Comment on lines +355 to +360
p.Send(turnDoneMsg{})
}()
result, err := p.Run()
<-done
// Keep the terminal attached until the canceled backend has actually
// stopped. Any late subprocess output is then discarded by the completed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 LOGICGROUNDED Race: p.Run() can return before run() goroutine closes done, causing nil-pointer dereference on final.output

RunTurnViewport starts run() in a goroutine that closes done after run() returns. p.Run() blocks until the Bubble Tea program exits (via tea.Quit). If p.Run() returns BEFORE the run() goroutine closes done — which happens when the user presses Enter or Ctrl+C, sending tea.Quit — the <-done receive on line 368 blocks. However, the type assertion final, ok := result.(turnViewportModel) on line 370 executes BEFORE <-done. If p.Run() returns an error (e.g., terminal detach failure), result is nil, and final.output on line 371 dereferences a nil pointer. The fix is to receive from done BEFORE the type assertion, or to check ok before accessing final.output.

Example:

If `p.Run()` returns an error (e.g., terminal detach failure), `result` is nil. The type assertion `final, ok := result.(turnViewportModel)` sets `final` to the zero value and `ok` to false. Accessing `final.output` on line 371 dereferences a nil pointer, causing a panic.

Current:

		p.Send(turnDoneMsg{})
	}()
	result, err := p.Run()
	<-done
	// Keep the terminal attached until the canceled backend has actually
	// stopped. Any late subprocess output is then discarded by the completed

Proposed:

result, err := p.Run()
<-done
if err != nil {
    return TurnInputResult{}
}
final, ok := result.(turnViewportModel)
if !ok {
    return TurnInputResult{}
}
Suggested change
p.Send(turnDoneMsg{})
}()
result, err := p.Run()
<-done
// Keep the terminal attached until the canceled backend has actually
// stopped. Any late subprocess output is then discarded by the completed
result, err := p.Run()
<-done
if err != nil {
return TurnInputResult{}
}
final, ok := result.(turnViewportModel)
if !ok {
return TurnInputResult{}
}
More Info
  • Threat model: A nil-pointer dereference crashes the process. This can occur if p.Run() returns an error (e.g., terminal I/O failure) or if the type assertion fails.
  • Specific code citations: p.Run() on line 367 returns (tea.Model, error). The type assertion on line 370 accesses final.output on line 371 before checking ok or err.
  • Existing protections: None. The <-done synchronization on line 368 is after the type assertion, not before it.
  • Proposed mitigation: Move the <-done receive to BEFORE the type assertion, or add a nil check: if err != nil || !ok { return TurnInputResult{} } before accessing final.output.
  • Alternative mitigations considered: Using a defer in the goroutine to send a result through a channel would be cleaner but more invasive.
  • Severity calibration: Score 5: nil-pointer dereference in a critical path (terminal I/O) that can crash the process under plausible conditions (terminal detach failure, type assertion failure).
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 355-360

Comment:
**Race: `p.Run()` can return before `run()` goroutine closes `done`, causing nil-pointer dereference on `final.output`**

`RunTurnViewport` starts `run()` in a goroutine that closes `done` after `run()` returns. `p.Run()` blocks until the Bubble Tea program exits (via `tea.Quit`). If `p.Run()` returns BEFORE the `run()` goroutine closes `done` — which happens when the user presses Enter or Ctrl+C, sending `tea.Quit` — the `<-done` receive on line 368 blocks. However, the type assertion `final, ok := result.(turnViewportModel)` on line 370 executes BEFORE `<-done`. If `p.Run()` returns an error (e.g., terminal detach failure), `result` is nil, and `final.output` on line 371 dereferences a nil pointer. The fix is to receive from `done` BEFORE the type assertion, or to check `ok` before accessing `final.output`.

Example:
If `p.Run()` returns an error (e.g., terminal detach failure), `result` is nil. The type assertion `final, ok := result.(turnViewportModel)` sets `final` to the zero value and `ok` to false. Accessing `final.output` on line 371 dereferences a nil pointer, causing a panic.

Threat model:
A nil-pointer dereference crashes the process. This can occur if `p.Run()` returns an error (e.g., terminal I/O failure) or if the type assertion fails.

Specific code citations:
`p.Run()` on line 367 returns `(tea.Model, error)`. The type assertion on line 370 accesses `final.output` on line 371 before checking `ok` or `err`.

Existing protections:
None. The `<-done` synchronization on line 368 is after the type assertion, not before it.

Proposed mitigation:
Move the `<-done` receive to BEFORE the type assertion, or add a nil check: `if err != nil || !ok { return TurnInputResult{} }` before accessing `final.output`.

Alternative mitigations considered:
Using a `defer` in the goroutine to send a result through a channel would be cleaner but more invasive.

Severity calibration:
Score 5: nil-pointer dereference in a critical path (terminal I/O) that can crash the process under plausible conditions (terminal detach failure, type assertion failure).

Suggested fix shape:
result, err := p.Run()
<-done
if err != nil {
    return TurnInputResult{}
}
final, ok := result.(turnViewportModel)
if !ok {
    return TurnInputResult{}
}

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +355 to +368
p.Send(turnDoneMsg{})
}()
result, err := p.Run()
<-done
// Keep the terminal attached until the canceled backend has actually
// stopped. Any late subprocess output is then discarded by the completed
// Bubble Tea program instead of escaping below the former input panel.
term.detachTurnProgram(p)
final, ok := result.(turnViewportModel)
if ok && final.output != nil {
// Bubble Tea only owns the live viewport. Re-emit the retained turn output
// once after it exits so normal terminal scrollback remains useful.
term.emit(final.output.String())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 LOGICGROUNDED Goroutine panic in run() leaves done channel unclosed, causing RunTurnViewport to hang forever

The run() function is called in a goroutine that closes done on exit. If run() panics, defer close(done) never executes, and <-done on line 368 blocks indefinitely. This hangs the entire terminal session. The fix is to recover from panics in the goroutine and close done even on panic.

Example:

If `run()` panics with a nil pointer dereference, the goroutine exits without closing `done`. `<-done` on line 368 blocks forever, hanging the terminal.

Current:

		p.Send(turnDoneMsg{})
	}()
	result, err := p.Run()
	<-done
	// Keep the terminal attached until the canceled backend has actually
	// stopped. Any late subprocess output is then discarded by the completed
	// Bubble Tea program instead of escaping below the former input panel.
	term.detachTurnProgram(p)
	final, ok := result.(turnViewportModel)
	if ok && final.output != nil {
		// Bubble Tea only owns the live viewport. Re-emit the retained turn output
		// once after it exits so normal terminal scrollback remains useful.
		term.emit(final.output.String())
	}

Proposed:

go func() {
    defer close(done)
    defer func() {
        if r := recover(); r != nil {
            p.Send(turnDoneMsg{})
        }
    }()
    run()
    p.Send(turnDoneMsg{})
}()
Suggested change
p.Send(turnDoneMsg{})
}()
result, err := p.Run()
<-done
// Keep the terminal attached until the canceled backend has actually
// stopped. Any late subprocess output is then discarded by the completed
// Bubble Tea program instead of escaping below the former input panel.
term.detachTurnProgram(p)
final, ok := result.(turnViewportModel)
if ok && final.output != nil {
// Bubble Tea only owns the live viewport. Re-emit the retained turn output
// once after it exits so normal terminal scrollback remains useful.
term.emit(final.output.String())
}
go func() {
defer close(done)
defer func() {
if r := recover(); r != nil {
p.Send(turnDoneMsg{})
}
}()
run()
p.Send(turnDoneMsg{})
}()
More Info
  • Threat model: A panic in the agent's run() function (e.g., nil pointer dereference, out-of-bounds slice access) causes the terminal to hang indefinitely, requiring a force-quit.
  • Specific code citations: The goroutine on lines 361-365 calls run() and closes done on normal return. No defer or recover is present.
  • Existing protections: None. The goroutine has no panic recovery.
  • Proposed mitigation: Wrap run() in a deferred recover: defer func() { if r := recover(); r != nil { /* log */ } close(done) }().
  • Alternative mitigations considered: Using p.Send(turnDoneMsg{}) in a deferred function would also work but is more complex.
  • Severity calibration: Score 4: hangs the terminal on panic, requiring a force-quit. Panics in agent code are plausible (e.g., nil pointer dereference in a tool handler).
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 355-368

Comment:
**Goroutine panic in `run()` leaves `done` channel unclosed, causing `RunTurnViewport` to hang forever**

The `run()` function is called in a goroutine that closes `done` on exit. If `run()` panics, `defer close(done)` never executes, and `<-done` on line 368 blocks indefinitely. This hangs the entire terminal session. The fix is to recover from panics in the goroutine and close `done` even on panic.

Example:
If `run()` panics with a nil pointer dereference, the goroutine exits without closing `done`. `<-done` on line 368 blocks forever, hanging the terminal.

Threat model:
A panic in the agent's `run()` function (e.g., nil pointer dereference, out-of-bounds slice access) causes the terminal to hang indefinitely, requiring a force-quit.

Specific code citations:
The goroutine on lines 361-365 calls `run()` and closes `done` on normal return. No `defer` or `recover` is present.

Existing protections:
None. The goroutine has no panic recovery.

Proposed mitigation:
Wrap `run()` in a deferred recover: `defer func() { if r := recover(); r != nil { /* log */ } close(done) }()`.

Alternative mitigations considered:
Using `p.Send(turnDoneMsg{})` in a deferred function would also work but is more complex.

Severity calibration:
Score 4: hangs the terminal on panic, requiring a force-quit. Panics in agent code are plausible (e.g., nil pointer dereference in a tool handler).

Suggested fix shape:
go func() {
    defer close(done)
    defer func() {
        if r := recover(); r != nil {
            p.Send(turnDoneMsg{})
        }
    }()
    run()
    p.Send(turnDoneMsg{})
}()

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +148 to +155
}

func safeOutputSuffix(out string, keep int) string {
start := len(out) - keep
if start <= 0 {
return out
}
if newline := strings.IndexByte(out[start:], '\n'); newline >= 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 LOGICGROUNDED safeOutputSuffix can produce a negative slice index when keep > len(out) and out contains ANSI sequences

safeOutputSuffix computes start = len(out) - keep. If keep > len(out), start is negative. The if start <= 0 check on line 152 returns out early — but ONLY for the raw out string. If out contains ANSI escape sequences, ansi.Strip(out) on line 160 produces a shorter plain string. The code then recomputes start = len(plain) - keep. If keep is larger than len(plain) but smaller than len(out), start is negative, and the if start < 0 check on line 162 resets it to 0. However, if keep is larger than BOTH len(out) and len(plain), the early return on line 152 fires, which is correct. The real bug is subtler: if keep is between len(plain) and len(out), the early return on line 152 does NOT fire (because start = len(out) - keep is positive), but after stripping ANSI, start = len(plain) - keep is negative. The if start < 0 check on line 162 handles this. BUT there's a path where start is computed from plain and then used to index plain — if keep is exactly len(plain), start is 0, which is fine. The actual bug: if keep is 0, start = len(out) - 0 is positive, the early return doesn't fire, plain is shorter, start = len(plain) - 0 is len(plain), and plain[start:] is empty — that's fine. The real issue is when keep is negative (should never happen with constants, but if the constants are changed to 0 or negative). More critically: if out is empty, len(out) - keep is negative, the early return fires. So the negative-index path is actually unreachable with the current constants. However, there's a DIFFERENT bug: when out contains ONLY ANSI sequences and no printable characters, ansi.Strip(out) returns an empty string. start = 0 - keep is negative, the if start < 0 check resets to 0, and plain[0:] returns an empty string — that's fine. The REAL bug is: start is computed as len(out) - keep on line 150. If out is very long and keep is small, start is large. The code then looks for a newline in out[start:]. If no newline is found, it falls through to the ANSI-stripping path. But start is still based on len(out), not len(plain). The code recomputes start = len(plain) - keep on line 161, which is correct. So the negative-index path is handled. The ACTUAL bug is: when keep is larger than len(plain) but start = len(out) - keep is still positive (because out has ANSI sequences), the early return on line 152 does NOT fire. Then start = len(plain) - keep is negative, and the if start < 0 check resets it to 0. This means the function returns plain[0:] (the entire stripped output) instead of the last keep bytes. This is a logic error: the function returns MORE output than intended when ANSI sequences are present and keep is close to the stripped length.

Example:

Input: `out` = 65KB of ANSI-styled text (50KB stripped), `keep` = 48KB. `start = 65KB - 48KB = 17KB` (positive, no early return). `plain` = 50KB. `start = 50KB - 48KB = 2KB`. Correct. But if `out` = 100KB of ANSI-styled text (40KB stripped), `keep` = 48KB: `start = 100KB - 48KB = 52KB` (positive, no early return). `plain` = 40KB. `start = 40KB - 48KB = -8KB` → reset to 0. Returns entire 40KB instead of last 48KB (which is the whole 40KB anyway — no harm). The harmful case: `out` = 70KB (60KB stripped), `keep` = 48KB: `start = 70KB - 48KB = 22KB` (positive). `plain` = 60KB. `start = 60KB - 48KB = 12KB`. Returns last 48KB — correct. The bug only manifests when `keep > len(plain)` but `keep <= len(out)`, which requires ANSI overhead > `len(out) - keep`. With `maxLiveOutput = 64KB` and `keptLiveOutput = 48KB`, the ANSI overhead must exceed 16KB for the bug to trigger. This is plausible with heavily styled output.

Current:

}

func safeOutputSuffix(out string, keep int) string {
	start := len(out) - keep
	if start <= 0 {
		return out
	}
	if newline := strings.IndexByte(out[start:], '\n'); newline >= 0 {

Proposed:

if len(plain) <= keep {
    return plain
}
start = len(plain) - keep
Suggested change
}
func safeOutputSuffix(out string, keep int) string {
start := len(out) - keep
if start <= 0 {
return out
}
if newline := strings.IndexByte(out[start:], '\n'); newline >= 0 {
if len(plain) <= keep {
return plain
}
start = len(plain) - keep
More Info
  • Threat model: When keep is larger than len(plain) but smaller than len(out) (due to ANSI sequences), safeOutputSuffix returns the entire stripped output instead of the last keep bytes. This violates the memory bound, potentially causing the live output buffer to grow beyond keptLiveOutput.
  • Specific code citations: Lines 150-152 compute start from len(out) and check start <= 0. Lines 160-161 recompute start from len(plain) but only after the early return on line 152 has already been bypassed.
  • Existing protections: The if start < 0 check on line 162 handles negative start but does not handle the case where start should be positive but keep > len(plain).
  • Proposed mitigation: After computing plain, check if len(plain) <= keep { return plain } before recomputing start.
  • Alternative mitigations considered: Compute start from len(plain) first, then check start <= 0.
  • Severity calibration: Score 4: violates the memory bound for live output when ANSI-heavy output is near the size limit. Could cause OOM under sustained streaming with ANSI sequences.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 148-155

Comment:
**`safeOutputSuffix` can produce a negative slice index when `keep > len(out)` and `out` contains ANSI sequences**

`safeOutputSuffix` computes `start = len(out) - keep`. If `keep > len(out)`, `start` is negative. The `if start <= 0` check on line 152 returns `out` early — but ONLY for the raw `out` string. If `out` contains ANSI escape sequences, `ansi.Strip(out)` on line 160 produces a shorter `plain` string. The code then recomputes `start = len(plain) - keep`. If `keep` is larger than `len(plain)` but smaller than `len(out)`, `start` is negative, and the `if start < 0` check on line 162 resets it to 0. However, if `keep` is larger than BOTH `len(out)` and `len(plain)`, the early return on line 152 fires, which is correct. The real bug is subtler: if `keep` is between `len(plain)` and `len(out)`, the early return on line 152 does NOT fire (because `start = len(out) - keep` is positive), but after stripping ANSI, `start = len(plain) - keep` is negative. The `if start < 0` check on line 162 handles this. BUT there's a path where `start` is computed from `plain` and then used to index `plain` — if `keep` is exactly `len(plain)`, `start` is 0, which is fine. The actual bug: if `keep` is 0, `start = len(out) - 0` is positive, the early return doesn't fire, `plain` is shorter, `start = len(plain) - 0` is `len(plain)`, and `plain[start:]` is empty — that's fine. The real issue is when `keep` is negative (should never happen with constants, but if the constants are changed to 0 or negative). More critically: if `out` is empty, `len(out) - keep` is negative, the early return fires. So the negative-index path is actually unreachable with the current constants. However, there's a DIFFERENT bug: when `out` contains ONLY ANSI sequences and no printable characters, `ansi.Strip(out)` returns an empty string. `start = 0 - keep` is negative, the `if start < 0` check resets to 0, and `plain[0:]` returns an empty string — that's fine. The REAL bug is: `start` is computed as `len(out) - keep` on line 150. If `out` is very long and `keep` is small, `start` is large. The code then looks for a newline in `out[start:]`. If no newline is found, it falls through to the ANSI-stripping path. But `start` is still based on `len(out)`, not `len(plain)`. The code recomputes `start = len(plain) - keep` on line 161, which is correct. So the negative-index path is handled. The ACTUAL bug is: when `keep` is larger than `len(plain)` but `start = len(out) - keep` is still positive (because `out` has ANSI sequences), the early return on line 152 does NOT fire. Then `start = len(plain) - keep` is negative, and the `if start < 0` check resets it to 0. This means the function returns `plain[0:]` (the entire stripped output) instead of the last `keep` bytes. This is a logic error: the function returns MORE output than intended when ANSI sequences are present and `keep` is close to the stripped length.

Example:
Input: `out` = 65KB of ANSI-styled text (50KB stripped), `keep` = 48KB. `start = 65KB - 48KB = 17KB` (positive, no early return). `plain` = 50KB. `start = 50KB - 48KB = 2KB`. Correct. But if `out` = 100KB of ANSI-styled text (40KB stripped), `keep` = 48KB: `start = 100KB - 48KB = 52KB` (positive, no early return). `plain` = 40KB. `start = 40KB - 48KB = -8KB` → reset to 0. Returns entire 40KB instead of last 48KB (which is the whole 40KB anyway — no harm). The harmful case: `out` = 70KB (60KB stripped), `keep` = 48KB: `start = 70KB - 48KB = 22KB` (positive). `plain` = 60KB. `start = 60KB - 48KB = 12KB`. Returns last 48KB — correct. The bug only manifests when `keep > len(plain)` but `keep <= len(out)`, which requires ANSI overhead > `len(out) - keep`. With `maxLiveOutput = 64KB` and `keptLiveOutput = 48KB`, the ANSI overhead must exceed 16KB for the bug to trigger. This is plausible with heavily styled output.

Threat model:
When `keep` is larger than `len(plain)` but smaller than `len(out)` (due to ANSI sequences), `safeOutputSuffix` returns the entire stripped output instead of the last `keep` bytes. This violates the memory bound, potentially causing the live output buffer to grow beyond `keptLiveOutput`.

Specific code citations:
Lines 150-152 compute `start` from `len(out)` and check `start <= 0`. Lines 160-161 recompute `start` from `len(plain)` but only after the early return on line 152 has already been bypassed.

Existing protections:
The `if start < 0` check on line 162 handles negative `start` but does not handle the case where `start` should be positive but `keep > len(plain)`.

Proposed mitigation:
After computing `plain`, check `if len(plain) <= keep { return plain }` before recomputing `start`.

Alternative mitigations considered:
Compute `start` from `len(plain)` first, then check `start <= 0`.

Severity calibration:
Score 4: violates the memory bound for live output when ANSI-heavy output is near the size limit. Could cause OOM under sustained streaming with ANSI sequences.

Suggested fix shape:
if len(plain) <= keep {
    return plain
}
start = len(plain) - keep

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +355 to +370
p.Send(turnDoneMsg{})
}()
result, err := p.Run()
<-done
// Keep the terminal attached until the canceled backend has actually
// stopped. Any late subprocess output is then discarded by the completed
// Bubble Tea program instead of escaping below the former input panel.
term.detachTurnProgram(p)
final, ok := result.(turnViewportModel)
if ok && final.output != nil {
// Bubble Tea only owns the live viewport. Re-emit the retained turn output
// once after it exits so normal terminal scrollback remains useful.
term.emit(final.output.String())
}
if err != nil || !ok {
return TurnInputResult{}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 LOGICGROUNDED Data race: m.result is written by the Update goroutine and read by RunTurnViewport without synchronization

RunTurnViewport calls p.Run() which runs the Bubble Tea event loop in a separate goroutine. The Update method writes to m.result (lines 100, 180, 193). After p.Run() returns, RunTurnViewport reads final.result on line 378. There is no happens-before relationship between the write in the Update goroutine and the read in RunTurnViewport. While p.Run() returning implies the event loop has stopped, the Go memory model does not guarantee visibility of writes made by the event loop goroutine to the calling goroutine without explicit synchronization. The fix is to use a channel or atomic value for the result.

Example:

The `Update` goroutine writes `m.result.Text = "user input"` and sends `tea.Quit`. `p.Run()` returns. The calling goroutine reads `final.result.Text` and observes `""` (the zero value) because the write was not flushed from the local cache.

Current:

		p.Send(turnDoneMsg{})
	}()
	result, err := p.Run()
	<-done
	// Keep the terminal attached until the canceled backend has actually
	// stopped. Any late subprocess output is then discarded by the completed
	// Bubble Tea program instead of escaping below the former input panel.
	term.detachTurnProgram(p)
	final, ok := result.(turnViewportModel)
	if ok && final.output != nil {
		// Bubble Tea only owns the live viewport. Re-emit the retained turn output
		// once after it exits so normal terminal scrollback remains useful.
		term.emit(final.output.String())
	}
	if err != nil || !ok {
		return TurnInputResult{}

Proposed:

resultCh := make(chan TurnInputResult, 1)
// In Update, instead of writing to m.result:
resultCh <- TurnInputResult{...}
// In RunTurnViewport:
select {
case res := <-resultCh:
    return res
case <-done:
    return TurnInputResult{}
}
Suggested change
p.Send(turnDoneMsg{})
}()
result, err := p.Run()
<-done
// Keep the terminal attached until the canceled backend has actually
// stopped. Any late subprocess output is then discarded by the completed
// Bubble Tea program instead of escaping below the former input panel.
term.detachTurnProgram(p)
final, ok := result.(turnViewportModel)
if ok && final.output != nil {
// Bubble Tea only owns the live viewport. Re-emit the retained turn output
// once after it exits so normal terminal scrollback remains useful.
term.emit(final.output.String())
}
if err != nil || !ok {
return TurnInputResult{}
resultCh := make(chan TurnInputResult, 1)
// In Update, instead of writing to m.result:
resultCh <- TurnInputResult{...}
// In RunTurnViewport:
select {
case res := <-resultCh:
return res
case <-done:
return TurnInputResult{}
}
More Info
  • Threat model: Under the Go memory model, the read of final.result on line 378 may observe a stale or partially written value, causing RunTurnViewport to return an empty or corrupted TurnInputResult.
  • Specific code citations: m.result.Text = text on line 180, m.result.Canceled = true on lines 181 and 193, and return final.result on line 378.
  • Existing protections: None. p.Run() returning does not establish a happens-before edge for writes to m.result.
  • Proposed mitigation: Send the result through a channel that RunTurnViewport receives from, or use sync/atomic for the result fields.
  • Alternative mitigations considered: Pass a pointer to a TurnInputResult that is only written before tea.Quit and read after p.Run() returns — this is still a data race per the Go memory model but is safe in practice on x86. A channel is the correct fix.
  • Severity calibration: Score 4: data race that can cause incorrect behavior (lost input) under the Go memory model. In practice, the race is unlikely to manifest on x86, but it is a real concurrency bug.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 355-370

Comment:
**Data race: `m.result` is written by the `Update` goroutine and read by `RunTurnViewport` without synchronization**

`RunTurnViewport` calls `p.Run()` which runs the Bubble Tea event loop in a separate goroutine. The `Update` method writes to `m.result` (lines 100, 180, 193). After `p.Run()` returns, `RunTurnViewport` reads `final.result` on line 378. There is no happens-before relationship between the write in the `Update` goroutine and the read in `RunTurnViewport`. While `p.Run()` returning implies the event loop has stopped, the Go memory model does not guarantee visibility of writes made by the event loop goroutine to the calling goroutine without explicit synchronization. The fix is to use a channel or atomic value for the result.

Example:
The `Update` goroutine writes `m.result.Text = "user input"` and sends `tea.Quit`. `p.Run()` returns. The calling goroutine reads `final.result.Text` and observes `""` (the zero value) because the write was not flushed from the local cache.

Threat model:
Under the Go memory model, the read of `final.result` on line 378 may observe a stale or partially written value, causing `RunTurnViewport` to return an empty or corrupted `TurnInputResult`.

Specific code citations:
`m.result.Text = text` on line 180, `m.result.Canceled = true` on lines 181 and 193, and `return final.result` on line 378.

Existing protections:
None. `p.Run()` returning does not establish a happens-before edge for writes to `m.result`.

Proposed mitigation:
Send the result through a channel that `RunTurnViewport` receives from, or use `sync/atomic` for the result fields.

Alternative mitigations considered:
Pass a pointer to a `TurnInputResult` that is only written before `tea.Quit` and read after `p.Run()` returns — this is still a data race per the Go memory model but is safe in practice on x86. A channel is the correct fix.

Severity calibration:
Score 4: data race that can cause incorrect behavior (lost input) under the Go memory model. In practice, the race is unlikely to manifest on x86, but it is a real concurrency bug.

Suggested fix shape:
resultCh := make(chan TurnInputResult, 1)
// In Update, instead of writing to m.result:
resultCh <- TurnInputResult{...}
// In RunTurnViewport:
select {
case res := <-resultCh:
    return res
case <-done:
    return TurnInputResult{}
}

How can I resolve this? If you propose a fix, please make it concise.

// Preserve text that was still being composed when the backend
// completed. Do not overwrite a prompt already submitted by Enter if the
// backend's cancellation races with the queued turnDoneMsg.
if m.result.Text == "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 LOGICGROUNDED turnDoneMsg handler trims whitespace from the draft text, losing intentional leading/trailing spaces

When the backend completes and sends turnDoneMsg, the handler preserves the draft text with m.result.Text = strings.TrimSpace(string(m.text)). TrimSpace removes leading and trailing whitespace, which discards intentional indentation or trailing spaces the user may have typed. The Enter key handler on line 179 also uses TrimSpace, which is correct for submitted input. But for a draft that is being preserved (not submitted), trimming is lossy. The fix is to preserve the raw text without trimming.

Example:

User types `    indented code` and the turn finishes before Enter. The preserved draft is `indented code` (leading spaces lost).

Suggested fix:

if m.result.Text == "" {
    m.result.Text = string(m.text)
}
More Info
  • Threat model: A user types leading spaces (e.g., for code indentation) and the turn finishes before they press Enter. The preserved draft loses the indentation, forcing the user to retype it.
  • Specific code citations: Line 100: m.result.Text = strings.TrimSpace(string(m.text)).
  • Existing protections: None. The Enter handler also trims, which is correct for submitted input, but the turnDoneMsg handler should preserve the raw draft.
  • Proposed mitigation: Change to m.result.Text = string(m.text) (without TrimSpace) in the turnDoneMsg handler.
  • Alternative mitigations considered: None.
  • Severity calibration: Score 3: minor data loss (whitespace) in an edge case (turn finishes while user is mid-edit). Not a crash or security issue.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 101

Comment:
**`turnDoneMsg` handler trims whitespace from the draft text, losing intentional leading/trailing spaces**

When the backend completes and sends `turnDoneMsg`, the handler preserves the draft text with `m.result.Text = strings.TrimSpace(string(m.text))`. `TrimSpace` removes leading and trailing whitespace, which discards intentional indentation or trailing spaces the user may have typed. The Enter key handler on line 179 also uses `TrimSpace`, which is correct for submitted input. But for a draft that is being preserved (not submitted), trimming is lossy. The fix is to preserve the raw text without trimming.

Example:
User types `    indented code` and the turn finishes before Enter. The preserved draft is `indented code` (leading spaces lost).

Threat model:
A user types leading spaces (e.g., for code indentation) and the turn finishes before they press Enter. The preserved draft loses the indentation, forcing the user to retype it.

Specific code citations:
Line 100: `m.result.Text = strings.TrimSpace(string(m.text))`.

Existing protections:
None. The Enter handler also trims, which is correct for submitted input, but the `turnDoneMsg` handler should preserve the raw draft.

Proposed mitigation:
Change to `m.result.Text = string(m.text)` (without TrimSpace) in the `turnDoneMsg` handler.

Alternative mitigations considered:
None.

Severity calibration:
Score 3: minor data loss (whitespace) in an edge case (turn finishes while user is mid-edit). Not a crash or security issue.

Suggested fix shape:
if m.result.Text == "" {
    m.result.Text = string(m.text)
}

How can I resolve this? If you propose a fix, please make it concise.

m.frame++
return m, turnViewportTick()
case tea.KeyMsg:
return m.updateKey(msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 TESTINGGROUNDED Missing test for nil output/live builder initialization race

The appendOutput method checks if m.output == nil and if m.live == nil to initialize builders, but there's no test covering this defensive initialization path. While the constructor initializes these fields, concurrent access or edge cases could trigger this path.

Example:

m := turnViewportModel{output: nil, live: nil}
m.appendOutput("test") // Should not panic

Suggested fix:

func TestTurnViewportAppendOutputHandlesNilBuilders(t *testing.T) {
    m := newTurnViewportModel("test", nil, nil)
    // Use reflection to set fields to nil
    reflect.ValueOf(&m).Elem().FieldByName("output").Set(reflect.ValueOf((*strings.Builder)(nil)))
    reflect.ValueOf(&m).Elem().FieldByName("live").Set(reflect.ValueOf((*strings.Builder)(nil)))
    
    m.appendOutput("should not panic")
    if m.output == nil || m.live == nil {
        t.Fatal("appendOutput should initialize nil builders")
    }
}

Why this wasn't caught: No test exercises the nil initialization path in appendOutput.

More Info
  • Threat model: If the model state becomes corrupted (e.g., through concurrent modification), the nil checks prevent panics but the behavior isn't verified.
  • Specific code citations: Lines 110-115 in turn_viewport.go show the nil checks before writing to output and live builders.
  • Existing protections: The constructor always initializes these fields, so the path may be unreachable in normal use.
  • Proposed mitigation: Add a test that creates a model with nil output/live fields (via reflection or custom constructor) and verifies appendOutput handles it gracefully.
  • Alternative mitigations considered: Could remove the nil checks if they're truly unreachable, but defensive programming is reasonable.
  • Severity calibration: Score 3 because it's a defensive code path without test coverage, but likely low risk.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 110

Comment:
**Missing test for nil output/live builder initialization race**

The `appendOutput` method checks `if m.output == nil` and `if m.live == nil` to initialize builders, but there's no test covering this defensive initialization path. While the constructor initializes these fields, concurrent access or edge cases could trigger this path.

Example:
m := turnViewportModel{output: nil, live: nil}
m.appendOutput("test") // Should not panic

Threat model:
If the model state becomes corrupted (e.g., through concurrent modification), the nil checks prevent panics but the behavior isn't verified.

Specific code citations:
Lines 110-115 in turn_viewport.go show the nil checks before writing to output and live builders.

Existing protections:
The constructor always initializes these fields, so the path may be unreachable in normal use.

Proposed mitigation:
Add a test that creates a model with nil output/live fields (via reflection or custom constructor) and verifies appendOutput handles it gracefully.

Alternative mitigations considered:
Could remove the nil checks if they're truly unreachable, but defensive programming is reasonable.

Severity calibration:
Score 3 because it's a defensive code path without test coverage, but likely low risk.

Suggested fix shape:
func TestTurnViewportAppendOutputHandlesNilBuilders(t *testing.T) {
    m := newTurnViewportModel("test", nil, nil)
    // Use reflection to set fields to nil
    reflect.ValueOf(&m).Elem().FieldByName("output").Set(reflect.ValueOf((*strings.Builder)(nil)))
    reflect.ValueOf(&m).Elem().FieldByName("live").Set(reflect.ValueOf((*strings.Builder)(nil)))
    
    m.appendOutput("should not panic")
    if m.output == nil || m.live == nil {
        t.Fatal("appendOutput should initialize nil builders")
    }
}

Why this wasn't caught:
No test exercises the nil initialization path in appendOutput.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +175 to +179
text := strings.TrimSpace(string(m.text))
if text == "" {
return m, nil
}
m.result.Text = text

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 LOGICGROUNDED Enter key submits even when m.result.Text is already set, potentially overwriting a previous submission

Low-confidence finding — expand to read

The Enter key handler on line 179 sets m.result.Text = text unconditionally. If turnDoneMsg has already set m.result.Text (the backend finished and preserved the draft), and then the Enter key event is processed (e.g., from a queued key event), the preserved draft is overwritten with the trimmed text. The turnDoneMsg handler has a guard (if m.result.Text == "") but the Enter handler does not. The fix is to add the same guard to the Enter handler.

More Info
  • Threat model: If the backend finishes and preserves a draft, then a queued Enter key event overwrites it with a potentially different (trimmed) value.
  • Specific code citations: Line 179: m.result.Text = text (no guard). Line 100: if m.result.Text == "" (has guard).
  • Existing protections: The turnDoneMsg handler guards against overwriting, but the Enter handler does not.
  • Proposed mitigation: Add if m.result.Text != "" { return m, nil } before setting m.result.Text in the Enter handler.
  • Alternative mitigations considered: None.
  • Severity calibration: Score 2: unlikely race condition (backend finishes AND Enter key is queued). Low impact (text overwrite, not data loss).

Example:

Backend finishes, `turnDoneMsg` sets `m.result.Text = "draft"`. Queued Enter key event fires, sets `m.result.Text = ""` (empty, because `m.text` was cleared by the backend completion). The submitted result is empty instead of the preserved draft.

Suggested fix:

if m.result.Text != "" {
    return m, nil
}
m.result.Text = text
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 175-179

Comment:
**Enter key submits even when `m.result.Text` is already set, potentially overwriting a previous submission**

The Enter key handler on line 179 sets `m.result.Text = text` unconditionally. If `turnDoneMsg` has already set `m.result.Text` (the backend finished and preserved the draft), and then the Enter key event is processed (e.g., from a queued key event), the preserved draft is overwritten with the trimmed text. The `turnDoneMsg` handler has a guard (`if m.result.Text == ""`) but the Enter handler does not. The fix is to add the same guard to the Enter handler.

Example:
Backend finishes, `turnDoneMsg` sets `m.result.Text = "draft"`. Queued Enter key event fires, sets `m.result.Text = ""` (empty, because `m.text` was cleared by the backend completion). The submitted result is empty instead of the preserved draft.

Threat model:
If the backend finishes and preserves a draft, then a queued Enter key event overwrites it with a potentially different (trimmed) value.

Specific code citations:
Line 179: `m.result.Text = text` (no guard). Line 100: `if m.result.Text == ""` (has guard).

Existing protections:
The `turnDoneMsg` handler guards against overwriting, but the Enter handler does not.

Proposed mitigation:
Add `if m.result.Text != "" { return m, nil }` before setting `m.result.Text` in the Enter handler.

Alternative mitigations considered:
None.

Severity calibration:
Score 2: unlikely race condition (backend finishes AND Enter key is queued). Low impact (text overwrite, not data loss).

Suggested fix shape:
if m.result.Text != "" {
    return m, nil
}
m.result.Text = text

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +148 to +163
}

func safeOutputSuffix(out string, keep int) string {
start := len(out) - keep
if start <= 0 {
return out
}
if newline := strings.IndexByte(out[start:], '\n'); newline >= 0 {
return out[start+newline+1:]
}

// A very long line has no safe textual boundary. Strip styling before
// slicing so an ANSI escape sequence can never be cut in half.
plain := ansi.Strip(out)
start = len(plain) - keep
if start < 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 LOGICGROUNDED safeOutputSuffix can return a string starting with a partial grapheme cluster when ANSI-stripped output begins with a combining character

Low-confidence finding — expand to read

The for start < len(plain) && !utf8.RuneStart(plain[start]) loop on line 163 advances past partial UTF-8 bytes but does not skip combining characters (zero-width joiners, variation selectors, combining diacritics). If plain[start] is a combining character, the returned string starts with a combining character that modifies the previous (now-truncated) base character, producing garbled output. The fix is to advance past combining characters as well, or to use a library that handles grapheme clusters.

More Info
  • Threat model: If the truncated output begins with a combining character (e.g., a zero-width joiner or combining diacritic), the terminal renders garbled text.
  • Specific code citations: Line 163: for start < len(plain) && !utf8.RuneStart(plain[start]) only checks for UTF-8 byte alignment, not grapheme cluster boundaries.
  • Existing protections: None. The loop only ensures start is at a valid UTF-8 rune start, not a grapheme cluster start.
  • Proposed mitigation: Use unicode.IsMark or a grapheme-cluster-aware library to advance past combining characters.
  • Alternative mitigations considered: Accept the minor rendering glitch as unlikely with typical CLI output.
  • Severity calibration: Score 2: cosmetic rendering issue with very unlikely input (combining characters at the truncation boundary in CLI output).

Example:

Output contains `e\u0301` (e + combining acute accent) at the truncation boundary. The returned string starts with `\u0301`, which combines with nothing, rendering as a standalone diacritic.

Suggested fix:

for start < len(plain) && (!utf8.RuneStart(plain[start]) || unicode.IsMark(rune(plain[start]))) {
    start++
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 148-163

Comment:
**`safeOutputSuffix` can return a string starting with a partial grapheme cluster when ANSI-stripped output begins with a combining character**

The `for start < len(plain) && !utf8.RuneStart(plain[start])` loop on line 163 advances past partial UTF-8 bytes but does not skip combining characters (zero-width joiners, variation selectors, combining diacritics). If `plain[start]` is a combining character, the returned string starts with a combining character that modifies the previous (now-truncated) base character, producing garbled output. The fix is to advance past combining characters as well, or to use a library that handles grapheme clusters.

Example:
Output contains `e\u0301` (e + combining acute accent) at the truncation boundary. The returned string starts with `\u0301`, which combines with nothing, rendering as a standalone diacritic.

Threat model:
If the truncated output begins with a combining character (e.g., a zero-width joiner or combining diacritic), the terminal renders garbled text.

Specific code citations:
Line 163: `for start < len(plain) && !utf8.RuneStart(plain[start])` only checks for UTF-8 byte alignment, not grapheme cluster boundaries.

Existing protections:
None. The loop only ensures `start` is at a valid UTF-8 rune start, not a grapheme cluster start.

Proposed mitigation:
Use `unicode.IsMark` or a grapheme-cluster-aware library to advance past combining characters.

Alternative mitigations considered:
Accept the minor rendering glitch as unlikely with typical CLI output.

Severity calibration:
Score 2: cosmetic rendering issue with very unlikely input (combining characters at the truncation boundary in CLI output).

Suggested fix shape:
for start < len(plain) && (!utf8.RuneStart(plain[start]) || unicode.IsMark(rune(plain[start]))) {
    start++
}

How can I resolve this? If you propose a fix, please make it concise.

cache: &turnViewportCache{},
width: 80,
height: 24,
cancelFn: cancelFn,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 PERFGROUNDED 100ms tick interval may cause unnecessary CPU wakeups during idle streaming

The turnViewportTick function wakes the Bubble Tea event loop every 100ms via tea.Tick. During long agent turns with sparse output, this creates a steady stream of wakeups that perform frame increments and cache checks even when nothing has changed. For a 30-second turn, this is 300 unnecessary timer-driven updates.

Suggested fix:

return tea.Tick(250*time.Millisecond, func(t time.Time) tea.Msg { return turnTickMsg(t) })
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 73

Comment:
**100ms tick interval may cause unnecessary CPU wakeups during idle streaming**

The `turnViewportTick` function wakes the Bubble Tea event loop every 100ms via `tea.Tick`. During long agent turns with sparse output, this creates a steady stream of wakeups that perform frame increments and cache checks even when nothing has changed. For a 30-second turn, this is 300 unnecessary timer-driven updates.

Suggested fix shape:
return tea.Tick(250*time.Millisecond, func(t time.Time) tea.Msg { return turnTickMsg(t) })

How can I resolve this? If you propose a fix, please make it concise.

return b.String()
}

func (m turnViewportModel) visibleOutput() string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 PERFGROUNDED ANSI wrapping on cache miss may be expensive for large live output

Low-confidence finding — expand to read

When the cache is invalidated (revision change or width change), visibleOutput calls ansi.Wrap(out, width, "") on the entire m.live.String() content. With maxLiveOutput at 64KB, this is a full-string scan and wrap operation. Under rapid streaming with width changes (terminal resize), this could cause momentary CPU spikes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/tui/turn_viewport.go
Line: 299

Comment:
**ANSI wrapping on cache miss may be expensive for large live output**

When the cache is invalidated (revision change or width change), `visibleOutput` calls `ansi.Wrap(out, width, "")` on the entire `m.live.String()` content. With `maxLiveOutput` at 64KB, this is a full-string scan and wrap operation. Under rapid streaming with width changes (terminal resize), this could cause momentary CPU spikes.

How can I resolve this? If you propose a fix, please make it concise.

@Desperado
Desperado merged commit 61b898f into main Jul 18, 2026
7 checks passed
@Desperado
Desperado deleted the agent/preserve-stream-input-boundary branch July 18, 2026 08:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant