Skip to content

fix(tui): single layout authority for viewport rows#124

Merged
esengine merged 2 commits into
mainfrom
fix/120-viewport-budget
May 2, 2026
Merged

fix(tui): single layout authority for viewport rows#124
esengine merged 2 commits into
mainfrom
fix/120-viewport-budget

Conversation

@esengine

@esengine esengine commented May 2, 2026

Copy link
Copy Markdown
Owner

Closes #120.

Why

StreamingCard, EditConfirm, ShellConfirm, and friends each read stdout.rows and computed their own row budget independently. Two scattered constants encoded the assumption: RESERVED_CHROME_ROWS = 14 (StreamingCard) and MODAL_OVERHEAD_ROWS = 18 (EditConfirm). Neither knew about the other.

Result: when an approval modal mounted while a tool card was still streaming (lamyc's video — write_file streaming + plan checkpoint approval), both layers claimed rows that together exceeded the viewport. Ink reflowed every frame, producing visible vertical jitter.

The class-of-bug part: every new bottom-anchored component added another independent calculation. A targeted patch would land another magic constant; the next bug just moves to a new component.

What

  • New src/cli/ui/layout/viewport-budget.tsxViewportBudgetProvider + useReserveRows(zone, { min, max }) hook + pure allocateRows() allocator. Priority-greedy: modal > plan-card > status > input > stream > safety.
  • Migrated StreamingCard, EditConfirm, ShellConfirm, PlanCheckpointConfirm, PlanConfirm, ChoiceConfirm, PromptInput. Each declares its zone claim; the provider runs allocation and feeds back actual budgets.
  • RESERVED_CHROME_ROWS and the standalone MODAL_OVERHEAD_ROWS reads are gone. useTotalRows() / useIsModalActive() replace direct useStdout reads in those paths.

Out of scope (follow-up)

  • PlanCard anchored render in App.tsx — small height, doesn't trigger this bug, can plug in next pass.
  • Status / activity rows (OngoingToolRow, ThinkingRow, etc.) — already conditionally hidden during modals, don't compete.
  • Full-screen pickers (SessionPicker, McpBrowser, PlanReviseEditor) — independently take the viewport, not a competing layer.

Verification

  • npm run verify — typecheck, lint, build, 1791 tests all green
  • 9 new test cases for the allocator (tests/viewport-budget.test.ts) covering single/multi claim, priority order, undersize-terminal fallback, and the lamyc scenario specifically

esengine added 2 commits May 2, 2026 04:18
Streaming cards and approval modals each read stdout.rows and
computed their own row budget independently — the bottom-anchor
modal could claim rows the streaming card had already counted on
and vice versa, producing visible jitter when both rendered the
same frame (lamyc's video).

Replaces the scattered RESERVED_CHROME_ROWS / MODAL_OVERHEAD_ROWS
constants with a priority-greedy allocator. Each component declares
{ min, max } rows for its zone via useReserveRows; the provider
runs allocation and feeds back actual budgets so consumers stay
within the viewport.

Migrated zones, in priority order:
  modal       (EditConfirm, ShellConfirm, PlanConfirm,
               PlanCheckpointConfirm, ChoiceConfirm)
  status      — left for follow-up; already conditionally hidden
  input       (PromptInput multi-line)
  stream      (StreamingCard)
  safety      — reserved margin

Skipped this pass: PlanCard anchor, plan-card zone, status rows.
Those don't trigger the lamyc bug and can plug into the same API
when their behavior is reviewed.
The hook had `ctx` in its effect deps, but the provider's context value
identity changes after every dispatch (the memo depends on `allocations`
which recomputes whenever `claims` changes). So claim → state update →
new ctx identity → effect re-fires (release + claim) → infinite loop.

Depend on `dispatch` (stable from useReducer) and the spec primitives
instead. Effect now fires once on mount, again only when min/max
actually change, and once on unmount.
@esengine esengine merged commit 1ff2c7c into main May 2, 2026
1 check passed
@esengine esengine deleted the fix/120-viewport-budget branch May 2, 2026 11:35
ChasLui pushed a commit to ChasLui/DeepSeek-Reasonix that referenced this pull request May 23, 2026
* fix(tui): single layout authority for viewport rows (esengine#120)

Streaming cards and approval modals each read stdout.rows and
computed their own row budget independently — the bottom-anchor
modal could claim rows the streaming card had already counted on
and vice versa, producing visible jitter when both rendered the
same frame (lamyc's video).

Replaces the scattered RESERVED_CHROME_ROWS / MODAL_OVERHEAD_ROWS
constants with a priority-greedy allocator. Each component declares
{ min, max } rows for its zone via useReserveRows; the provider
runs allocation and feeds back actual budgets so consumers stay
within the viewport.

Migrated zones, in priority order:
  modal       (EditConfirm, ShellConfirm, PlanConfirm,
               PlanCheckpointConfirm, ChoiceConfirm)
  status      — left for follow-up; already conditionally hidden
  input       (PromptInput multi-line)
  stream      (StreamingCard)
  safety      — reserved margin

Skipped this pass: PlanCard anchor, plan-card zone, status rows.
Those don't trigger the lamyc bug and can plug into the same API
when their behavior is reviewed.

* fix(viewport-budget): break infinite useEffect loop in useReserveRows

The hook had `ctx` in its effect deps, but the provider's context value
identity changes after every dispatch (the memo depends on `allocations`
which recomputes whenever `claims` changes). So claim → state update →
new ctx identity → effect re-fires (release + claim) → infinite loop.

Depend on `dispatch` (stable from useReducer) and the spec primitives
instead. Effect now fires once on mount, again only when min/max
actually change, and once on unmount.
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.

TUI: bottom-anchor modal + streaming top card race causes repaint glitch

1 participant