Skip to content

fix: keep the ask UI within the terminal viewport - #13

Open
chrisgoddard wants to merge 1 commit into
devkade:devfrom
chrisgoddard:fix/overflow-viewport
Open

fix: keep the ask UI within the terminal viewport#13
chrisgoddard wants to merge 1 commit into
devkade:devfrom
chrisgoddard:fix/overflow-viewport

Conversation

@chrisgoddard

@chrisgoddard chrisgoddard commented Aug 21, 2026

Copy link
Copy Markdown

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.ts holds the viewport arithmetic so the single-question and
    tabbed UIs share one implementation. Shift+↑ / Shift+↓ scroll without moving
    the 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() calls
    fullRender(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 its
preferred 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) 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:

terminal rows − component chrome − DEFAULT_RESERVED_ROWS

DEFAULT_RESERVED_ROWS is 8, and deliberately a constant rather than a share
of 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, with PgUp / PgDn kept as a secondary
binding. 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 of
Shift+arrow, none of which collide with a plain arrow key. Editor does not use
shift+arrows, and ctrl+shift+up/down (pi's history navigation) is a different
chord.

Behaviour

  • The anchor is the active option including its wrapped inline note, so the
    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 options are what a reader has to act on, so they get the viewport ahead of
    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.
  • 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.

Validation

  • npm run check passes locally (typecheck + tests + coverage gate) — 144
    tests, exit 0
  • Added/updated tests for new behavior or branch changes — 77 new tests
  • No existing contract tests were weakened/removed without justification —
    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.ts is at 100%/100%.

The new tests were verified to fail without the fix, so they cover the defect
rather than restating current behaviour:

suite fails against unfixed code
ask-ui-overflow.test.ts (OKR scenarios) 17 of 20
ask-ui-render-layout.test.ts (real renderer) 12 of 17

test/ask-ui-render-layout.test.ts exists because an earlier revision of this
change 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

Key result Status
Usable at 12 / 16 / 20 rows Parameterised tests at all three heights
Overflow navigability Covered — cursor and submit area reachable
Viewport correctness after ↑↓, ←→, note edit entry/exit Covered
Parity across single / tabs / submit / Other Scenarios 1–5
≥5 overflow-focused test scenarios 7 scenarios
npm run check passes, quality bar maintained Passes; coverage improved
README updated New "Tall Questions and Small Terminals" section

Ask Session Logging Contract (if ask result formatting changed)

Not applicable — this is a rendering and input-handling change. No result
formatting, session text, or details payload was touched. The unchanged
index.test.ts suite covers that contract and still passes.

Risk & Rollback

  • Risk level: Low. Confined to the render path of the two UI modules plus one
    new module. No schema, no tool result shape, no session text changes. The
    bounding is inert when terminal height is unavailable.
  • Rollback plan: revert the single commit. No migration, no persisted state.

Notes for Reviewer

  • DEFAULT_RESERVED_ROWS = 8 is the judgement call. It is the one number that
    encodes 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.
  • Chrome row accounting in each render() is worth a close look:
    baseChromeRows is 3 for the single-question UI (two rules + hint) and 6 for the
    tabbed 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.
  • hasUserScrolled lets explicit scrolling win over anchor-following until the
    next selection change; preferScrollOffset carries that into sliceViewport.
  • I did not use ScrollView: isViewportTUI is false for the main-screen TUI, so
    scroll layout nodes are not honoured there. The windowing has to live in the
    component's own render.
  • Verified interactively under Herdr on Ghostty at 24 and 54 rows: no flicker,
    scrollback intact, Shift+↑/↓ scrolls, plain ↑/↓ still selects, and a
    prompt that fits renders whole rather than being windowed.

@chrisgoddard
chrisgoddard force-pushed the fix/overflow-viewport branch from 11d7d69 to ffcedea Compare August 21, 2026 22:41
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
chrisgoddard force-pushed the fix/overflow-viewport branch from ffcedea to 648804e Compare August 22, 2026 17:49
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