Skip to content

fix(ui): pin input box to bottom, stop scroll-up during streaming#15

Closed
code-crusher wants to merge 1 commit into
mainfrom
fix/pin-input-box-bottom
Closed

fix(ui): pin input box to bottom, stop scroll-up during streaming#15
code-crusher wants to merge 1 commit into
mainfrom
fix/pin-input-box-bottom

Conversation

@code-crusher

Copy link
Copy Markdown
Member

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:

  1. 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.

  2. Viewport-capped row rendering. Added estimateRowLines() to estimate how many terminal lines each row occupies (accounting for wrapping). visibleRows shows only the last N rows that fit above the input box. Available height is computed as termRows - bottomHeight - middleHeight, where bottomHeight accounts for the input box, status bar, queue, and any open pickers/prompts.

  3. 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 with tailForHeight() (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-done fires — nothing is lost, it just scrolls within the viewport instead of scrolling the entire terminal.

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.
@matterai-app

matterai-app Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary By MatterAI MatterAI logo

🔄 What Changed

This PR implements a custom viewport management system for the Ink-based TUI. It replaces the default scrolling behavior with a dynamic rendering logic that calculates terminal dimensions, estimates the height of message rows (accounting for text wrapping), and slices the visible rows to ensure the input box remains pinned to the bottom of the terminal during streaming and interaction.

🔍 Impact of the Change

Significantly improves the user experience by preventing "scroll-up" jitter during AI response streaming. By manually managing the viewport height, the application ensures that the most relevant content is always visible and the UI chrome (input, status bars, pickers) stays fixed at the bottom, regardless of terminal size.

📁 Total Files Changed

Click to Expand
File ChangeLog
Viewport Management src/ui/App.tsx Implemented terminal height detection, row height estimation logic, and dynamic row slicing to pin the input box.

🧪 Test Added/Recommended

Recommended

  • Unit Tests: Add tests for estimateRowLines and tailForHeight with various terminal widths and multi-line strings to ensure wrapping logic is accurate.
  • Edge Case Testing: Verify UI behavior when the terminal height is extremely small (e.g., < 10 rows).

🔒Security Vulnerabilities

No security vulnerabilities detected. The changes are focused on UI rendering logic. 🛡️

@matterai-app matterai-app Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧪 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 middleHeight for streaming reasoning is hardcoded to 4 (1 header + 3 content lines). The old code respected expandReasoningRef.current ? 30 : 3 to 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 the middleHeight won't account for the expanded view, causing layout miscalculation.

Fix: Use expandReasoningRef.current to 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: tailForHeight is called with a hardcoded 3 for maxLines, but the old code used expandReasoningRef.current ? 30 : 3 to 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.current when 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)
+      : "";

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant