fix(ui): pin input box to bottom, stop scroll-up during streaming#15
fix(ui): pin input box to bottom, stop scroll-up during streaming#15code-crusher wants to merge 1 commit into
Conversation
Remove <Static> which permanently wrote rows to stdout and caused uncontrolled terminal scrolling when long responses completed. Render rows in the dynamic region with viewport-capped visibility so only the rows that fit above the input box are shown. Add a spacer to pin the input box + status bar at the bottom when content is short. Cap streaming text with tailForHeight() which accounts for line wrapping so it never pushes the input box off-screen.
There was a problem hiding this comment.
🧪 PR Review is completed: Viewport management implementation is solid, but the expand/collapse reasoning feature is broken — the old expandReasoningRef.current ? 30 : 3 logic is replaced with a hardcoded 3 in both the display calculation and the middleHeight estimate.
⬇️ Low Priority Suggestions (2)
src/ui/App.tsx (2 suggestions)
Location:
src/ui/App.tsx(Lines 1344-1344)🟡 Behavior Regression
Issue: The
middleHeightfor streaming reasoning is hardcoded to4(1 header + 3 content lines). The old code respectedexpandReasoningRef.current ? 30 : 3to show up to 30 lines when expanded. If the expand reasoning toggle is still wired to a key binding, pressing it will silently do nothing, and themiddleHeightwon't account for the expanded view, causing layout miscalculation.Fix: Use
expandReasoningRef.currentto dynamically calculate the reasoning height allocation.Impact: Restores the expand/collapse reasoning feature and ensures correct viewport height calculation when reasoning is expanded.
- if (streamingReasoning) middleHeight += 4; + if (streamingReasoning) middleHeight += 1 + (expandReasoningRef.current ? 30 : 3);Location:
src/ui/App.tsx(Lines 1376-1378)🟡 Behavior Regression
Issue:
tailForHeightis called with a hardcoded3formaxLines, but the old code usedexpandReasoningRef.current ? 30 : 3to allow users to expand the reasoning view. This silently breaks the expand/collapse reasoning toggle — the ref is still in the code but no longer affects the display.Fix: Respect
expandReasoningRef.currentwhen computing the reasoning display lines, consistent with the old behavior.Impact: Restores the expand/collapse reasoning feature so the toggle key binding works as expected.
- const streamingReasoningDisplay = streamingReasoning - ? tailForHeight(streamingReasoning, 3, wrapWidth) - : ""; + const reasoningLines = expandReasoningRef.current ? 30 : 3; + const streamingReasoningDisplay = streamingReasoning + ? tailForHeight(streamingReasoning, reasoningLines, wrapWidth) + : "";
Problem
When the agent response buffered, the CLI view kept scrolling up, dragging the input box and status bar with it. The input box also floated in the middle of the screen when content was short instead of being pinned at the bottom.
Root Cause
The app used Ink's
<Static>component to render committed rows.<Static>permanently writes each item to stdout and never removes it, so when a long response completed (text-done), the full text was flushed to stdout as a new static row, causing the terminal to scroll up. Additionally, when content was short, nothing pinned the input box to the bottom.Fix
Three changes in
src/ui/App.tsx:Removed
<Static>entirely. Rows are now rendered in the dynamic region via{visibleRows.map(...)}. Ink re-renders the whole tree each frame instead of permanently writing to stdout, so no uncontrolled scrolling.Viewport-capped row rendering. Added
estimateRowLines()to estimate how many terminal lines each row occupies (accounting for wrapping).visibleRowsshows only the last N rows that fit above the input box. Available height is computed astermRows - bottomHeight - middleHeight, wherebottomHeightaccounts for the input box, status bar, queue, and any open pickers/prompts.Bottom-pinned input box via spacer. When content is short, a
<Box height={spacerHeight} />fills the gap between the content and the input box, pinning the input box + status bar to the bottom. Streaming text is capped withtailForHeight()(which accounts for line wrapping) to the space left after committed rows, so it never grows tall enough to push the input box off-screen.The full response is still committed to the transcript as a row once
text-donefires — nothing is lost, it just scrolls within the viewport instead of scrolling the entire terminal.