fix: keep the ask UI within the terminal viewport - #13
Open
chrisgoddard wants to merge 1 commit into
Open
Conversation
chrisgoddard
force-pushed
the
fix/overflow-viewport
branch
from
August 21, 2026 22:41
11d7d69 to
ffcedea
Compare
Closes devkade#5. When the rendered ask UI is taller than the terminal, pi-tui's main-screen renderer cannot update it differentially. The first changed line sits above the previous viewport top, so doRender() takes its `firstChanged < prevViewportTop` branch and calls fullRender(true), which emits `\x1b[2J\x1b[H\x1b[3J` -- clear screen, home, erase scrollback -- on every keystroke. That is what makes a tall prompt flicker, and why the scrollback cannot be scrolled back through: it is being destroyed on each repaint. Measured against the real renderer at 80x24 with 30 options, walking the cursor down and back up: 20 full redraws and 20 scrollback wipes before, 0 and 0 after. Separate line generation from viewport slicing, as the OKR suggests, and add src/ask-viewport.ts holding the arithmetic so the single-question and tabbed UIs share one implementation: - resolveViewportHeight() derives the body budget from the terminal height. - sliceViewport() windows the body and keeps an anchor range visible. - The anchor is the active option including its wrapped inline note, so the editing caret cannot scroll off-screen; on the submit tab it is the status line, so the reason a submit is blocked stays visible. - A separate priority region names the block that should be revealed as fully as possible, ahead of anything preceding it. Scrolling only far enough to reveal the active option left it on the bottom edge with the description filling the space above, so a long description reduced the prompt to a single visible choice. The options block is the priority region, and on the submit tab it is the answer list and submit status. When the region fits it sits at the bottom of the viewport so every line of it shows; when it does not, the viewport goes entirely to it. On a 24-row terminal with 6 options and a 60-line description that is 1 visible option before and all 7 after. Sizing has to account for pi's own layout. The component is mounted in the editor container, and the widget and footer containers render beneath it; it cannot measure them, because render(width) receives no height and main-screen mode gives it no layout callback. Differential rendering survives as long as the ask UI plus that trailing chrome fits on screen, so the budget is the terminal height minus the component's own chrome minus DEFAULT_RESERVED_ROWS. That reserve is a constant, not a share of the screen. The chrome beneath the component is itself roughly constant, so a proportional allowance would withhold ever more rows the taller the terminal got and window content that had room to render. DEFAULT_RESERVED_ROWS is 8: sweeping it against the layout suite, which drives the real renderer with 0 to 8 rows of chrome below the component, 7 is the smallest value that never erases the scrollback, so 8 leaves one row of margin. A deeper dock than the reserve reintroduces the redraw. Scrolling is bound to Shift+Up/Shift+Down, with PgUp/PgDn kept as a secondary binding. Terminal multiplexers such as Herdr, tmux and screen commonly bind PgUp/PgDn to their own scrollback and never forward them, so that binding alone does nothing in a real session. pi-tui matches the xterm (CSI 1;2A), rxvt (CSI a) and Kitty encodings of Shift+arrow, none of which collide with a plain arrow key, and pi's Editor does not use shift+arrows. Behaviour: - Scrolling never changes the selection and stays available while the inline note editor is open. - Moving the selection, editing a note, or switching tabs re-follows the active target. - Each tab keeps its own scroll offset. - A `↑ n more · ↓ n more • Shift+↑/↓ scroll` hint appears only when content is hidden. When the host reports no terminal size, rendering stays unbounded, so existing callers and tests are unaffected. Tests cover the OKR's seven scenarios at 12, 16 and 20 rows, every accepted Shift+arrow encoding, and -- in test/ask-ui-render-layout.test.ts -- the component mounted in a real TUI between a transcript above and a dock below, asserting the renderer emits no erase-scrollback sequence across dock sizes 0-8 and terminal heights 12-40. That layout suite exists because an earlier revision passed tests that rendered the component in isolation and still flickered in practice; it is also what caught the reserve being too small once the proportional cap was removed. Verification: `npm run check` passes (144 tests, exit 0). Overall line coverage rises from 95.21% to 96.29%; src/ask-viewport.ts is at 100%.
chrisgoddard
force-pushed
the
fix/overflow-viewport
branch
from
August 22, 2026 17:49
ffcedea to
648804e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
What changed? The ask UI no longer renders more lines than the terminal can
show. Line generation is separated from viewport slicing, and a new
src/ask-viewport.tsholds the viewport arithmetic so the single-question andtabbed UIs share one implementation.
Shift+↑/Shift+↓scroll without movingthe selection, and the active target stays visible.
Why was this needed? Fixes Unable to scroll upwards when prompt/context/options height exceeds terminal height #5. When the rendered UI is taller than the
terminal, pi-tui's main-screen renderer cannot update differentially: the first
changed line is above the previous viewport top, so
doRender()callsfullRender(true), which emits\x1b[2J\x1b[H\x1b[3J— clear screen, home,erase scrollback — on every keystroke. That is both reported symptoms at
once: the flicker, and being unable to scroll up, because the scrollback is
destroyed on each repaint rather than merely scrolled away from.
Measured against the real renderer at 80x24 with 30 options, walking the cursor
down and back up: 20 full redraws and 20 scrollback wipes before, 0 and 0
after.
This implements
docs/okr/2026-03-30-overflow-scroll-okrs.md, following itspreferred design shape (
scrollOffset/viewportHeight/activeLineIndex),with the reuse goal met by the shared helper.
Sizing accounts for pi's layout
The component is mounted in the editor container, and the widget and footer
containers render below it. It cannot measure them:
render(width)receivesno height and main-screen mode gives it no layout callback.
Differential rendering survives as long as the ask UI plus that trailing chrome
fits on screen, so the budget is:
DEFAULT_RESERVED_ROWSis 8, and deliberately a constant rather than a shareof the screen. The chrome beneath the component is itself roughly constant, so a
proportional allowance withholds ever more rows the taller the terminal gets, and
windows content that had room to render.
The value came from sweeping it against the layout suite, which drives the real
renderer with 0–8 rows of chrome below the component: 7 is the smallest value
that never erases the scrollback, so 8 leaves one row of margin. A dock deeper
than the reserve reintroduces the redraw — that limit is documented at the
constant.
Scroll binding
Shift+↑/Shift+↓is primary, withPgUp/PgDnkept as a secondarybinding. Terminal multiplexers commonly bind PgUp/PgDn to their own scrollback and
never forward them, so that binding alone does nothing in a real session — this
was reproduced under Herdr, where the first revision's scroll keys were inert.
pi-tui matches the xterm (
CSI 1;2A), rxvt (CSI a) and Kitty encodings ofShift+arrow, none of which collide with a plain arrow key.
Editordoes not useshift+arrows, and
ctrl+shift+up/down(pi's history navigation) is a differentchord.
Behaviour
editing caret cannot scroll off-screen. On the submit tab the anchor is the
status line, so the reason a submit is blocked stays visible.
the question and description above them. Scrolling only far enough to reveal
the active option left it on the bottom edge with the description filling the
space above, reducing a long prompt to one visible choice. A 24-row terminal
with 6 options and a 60-line description showed 1 option before, all 7
after. Where the options cannot all fit, the viewport fills with options
rather than description.
editor is open.
target.
↑ n more · ↓ n more • Shift+↑/↓ scrollhint appears only when content ishidden.
callers and tests are unaffected.
Validation
npm run checkpasses locally (typecheck + tests + coverage gate) — 144tests, exit 0
all 67 pre-existing tests pass unchanged
Coverage rises rather than falls: overall lines 95.21% → 96.29%, functions
90.00% → 91.14%.
src/ask-viewport.tsis at 100%/100%.The new tests were verified to fail without the fix, so they cover the defect
rather than restating current behaviour:
ask-ui-overflow.test.ts(OKR scenarios)ask-ui-render-layout.test.ts(real renderer)test/ask-ui-render-layout.test.tsexists because an earlier revision of thischange passed tests that rendered the component in isolation and still
flickered in practice. It mounts the component in a real TUI between a transcript
above and a dock below, then asserts the renderer emits no erase-scrollback
sequence across dock sizes 0–8 and terminal heights 12–40.
OKR key results
Othernpm run checkpasses, quality bar maintainedAsk Session Logging Contract (if
askresult formatting changed)Not applicable — this is a rendering and input-handling change. No result
formatting, session text, or
detailspayload was touched. The unchangedindex.test.tssuite covers that contract and still passes.Risk & Rollback
new module. No schema, no tool result shape, no session text changes. The
bounding is inert when terminal height is unavailable.
Notes for Reviewer
DEFAULT_RESERVED_ROWS = 8is the judgement call. It is the one number thatencodes an assumption about your layout rather than something I could measure
from inside the component. Too low and the scrollback wipes return; too high and
short terminals lose reading space for nothing. If you know the real worst-case
depth of the chrome below the editor container, that number is better than my
sweep.
render()is worth a close look:baseChromeRowsis 3 for the single-question UI (two rules + hint) and 6 for thetabbed UI (two rules, tab bar, blank, separator, hint). Overflow adds one row for
the indicator, so the budget is recomputed when the body does not fit — otherwise
adding the indicator could re-overflow by one line.
hasUserScrolledlets explicit scrolling win over anchor-following until thenext selection change;
preferScrollOffsetcarries that intosliceViewport.ScrollView:isViewportTUIis false for the main-screen TUI, soscroll layout nodes are not honoured there. The windowing has to live in the
component's own
render.scrollback intact,
Shift+↑/↓scrolls, plain↑/↓still selects, and aprompt that fits renders whole rather than being windowed.