ui: print the transcript into the terminal's scrollback so the trackpad scrolls it - #111
Conversation
…ad scrolls it The chat owned its own viewport, so the terminal's scrollback was empty and wheel/trackpad scrolling did nothing unless you armed mouse capture with ctrl+s, which then cost you text selection and clickable links. Settled rows now go through ink's Static: printed once into the real scrollback and never repainted, so scrolling, select/copy and links are the terminal's own. That trades away what a repaintable window could do, so those move rather than disappear. ctrl+r opens the transcript on the alternate screen with folding and arrow-key nav, and the session list becomes a full-screen picker on ctrl+j; it could not stay pinned under a region that prints into scrollback. Both hop buffers through ink's suspendTerminal, which erases the old frame and forces a clean redraw, and withholding the flush while they are up keeps rows out of the buffer that is about to be destroyed. Nothing hosts the chat in a rectangle any more, so the pane props, the ctrl+s capture toggle and the sessionBar row cap go with it.
There was a problem hiding this comment.
Important
Looks good to me! 👍
Reviewed afdbb8a in 10 minutes, 51 seconds.
- Reviewed
1commit with1365lines of code in14files - Ran
1review agent producing0comments where0were posted - This pipeline runs no gatekeeper, so findings are posted as written.
- View full details on ellipsis.dev
This review was created by . You can tag
@ellipsis in this pull request.
Painted surfaces and scrollback printing are incompatible. Ink erases only its current frame's height, so a tall painted frame replaced by the short content-sized chat leaves its fill on screen, and a flushed row carries its own fill into scrollback where nothing will ever repaint it. The result was stale bands striping the terminal. Structure moves onto glyphs and foreground colour: the canvas, the header band tint, the composer panel, the message-block tint and the session-row highlight bars all go, and selection is the cyan marker it already was. That takes the surface tokens with it, along with the quantization that existed to keep three near-black fills apart on a 256-colour terminal, and the panel/pad plumbing in the row builder.
There was a problem hiding this comment.
Caution
Changes requested ❌ — 1 issue
Incrementally reviewed 3038ecc in 6 minutes, 41 seconds.
- Reviewed
1commit with446lines of code in7files - Ran
1review agent producing1comment where1was posted - This pipeline runs no gatekeeper, so findings are posted as written.
- View full details on ellipsis.dev
This review was created by . You can tag
@ellipsis in this pull request.
| const { stream, output } = fakeTty() | ||
| const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), patchConsole: false }) | ||
| await settle() | ||
| costTick(store, 50_000) | ||
| await settle() | ||
| app.unmount() | ||
| const raw = output() | ||
| expect(raw).not.toMatch(/\u001B\[[0-9;]*4[0-7]m/) | ||
| expect(raw).not.toMatch(/\u001B\[[0-9;]*10[0-7]m/) | ||
| expect(raw).not.toContain('48;2;') | ||
| expect(raw).not.toContain('48;5;') |
There was a problem hiding this comment.
This test cannot fail: under vitest chalk.level === 0, so ink emits no SGR at all and none of the four assertions can ever match — force colour for the render, and anchor the 40-47 regex on a parameter boundary.
I re-added backgroundColor="#262523" to every transcript row in RowLine and ran the file: all 5 tests still passed. With chalk.level = 3 set for the render it correctly fails ([48;2;38;37;35m…). Separately, [0-9;]*4[0-7]m also matches a truecolor FOREGROUND whose last channel is 41-47 — theme.active/syntaxLiteral (#d9bd8d) is 38;2;217;189;141m, so once colour is forced this assertion trips on any amber glyph in the frame.
| const { stream, output } = fakeTty() | |
| const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), patchConsole: false }) | |
| await settle() | |
| costTick(store, 50_000) | |
| await settle() | |
| app.unmount() | |
| const raw = output() | |
| expect(raw).not.toMatch(/\u001B\[[0-9;]*4[0-7]m/) | |
| expect(raw).not.toMatch(/\u001B\[[0-9;]*10[0-7]m/) | |
| expect(raw).not.toContain('48;2;') | |
| expect(raw).not.toContain('48;5;') | |
| const { stream, output } = fakeTty() | |
| // Under vitest chalk.level is 0, so ink emits no SGR at all and the | |
| // assertions below pass on any code, background or not. Force truecolor for | |
| // this render (theme.ts no longer reads chalk.level, so this is safe). | |
| const level = chalk.level | |
| chalk.level = 3 | |
| const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), patchConsole: false }) | |
| await settle() | |
| costTick(store, 50_000) | |
| await settle() | |
| app.unmount() | |
| chalk.level = level | |
| const raw = output() | |
| // Anchored on a parameter boundary: a bare `[0-9;]*4[0-7]m` also matches a | |
| // truecolor FOREGROUND whose last channel is 41-47 — theme.active, | |
| // #d9bd8d, is 38;2;217;189;141m. | |
| expect(raw).not.toMatch(/\u001B\[(?:[0-9;]*;)?(?:4[0-7]|10[0-7])m/) | |
| expect(raw).not.toContain('48;2;') | |
| expect(raw).not.toContain('48;5;') |
The opening "Connected to ellipsis.dev" baked its glyph into the text span while every other row puts its mark in the gutter, so the app's first line sat one gutter-width right of everything under it. It takes the gutter now. Replaces the "ctrl+r transcript / ctrl+j sessions" hint with a command menu: typing / lists the commands with descriptions, each character narrows by prefix, up/down walk them, tab completes, and esc dismisses. /stop, /transcript, /sessions and /exit (aliased /quit) cover what the keys did. Enter completes a partial name and submits a complete one, so it never refuses the command the menu is pointing at. An unknown command is refused rather than sent on as prose, since the agent cannot tell a typo'd command from a message you meant. Also brings back the one background the new layout can support: the composer's. It lives in the live frame for the whole session, so it is repainted every frame and never flushed to scrollback, which is what made the other fills unworkable.
There was a problem hiding this comment.
Caution
Changes requested ❌ — 4 issues
Incrementally reviewed 041f426 in 10 minutes, 19 seconds.
- Reviewed
1commit with505lines of code in6files - Ran
1review agent producing4comments where4were posted - This pipeline runs no gatekeeper, so findings are posted as written.
- View full details on ellipsis.dev
This review was created by . You can tag
@ellipsis in this pull request.
| if (!command) { | ||
| setNotice(`✗ no such command: ${text.split(/\s/)[0]} · type / to see them`) | ||
| return |
There was a problem hiding this comment.
A refused slash line is thrown away: submit clears the composer on line 647 before this refusal, so the typed message is gone and must be retyped — restore it when refusing.
Rendered against a fake TTY: typing /tmp/agent.log is full, please rotate it then enter posts nothing, empties the composer, and shows ✗ no such command: /tmp/agent.log. Before this commit the same line was forwarded to the agent, so nothing was lost. Separately, /stop now is refused with ✗ no such command: /stop — naming a command that does exist; the argument is the problem, not the name.
| if (!command) { | |
| setNotice(`✗ no such command: ${text.split(/\s/)[0]} · type / to see them`) | |
| return | |
| if (!command) { | |
| // The line is the user's, and this was the only copy of it. | |
| setComposer({ text: raw, cursor: raw.length }) | |
| setNotice(`✗ no such command: ${text.split(/\s/)[0]} · type / to see them`) | |
| return |
| export function isCommandInput(text: string): boolean { | ||
| return text.startsWith('/') | ||
| } |
There was a problem hiding this comment.
startsWith('/') claims any line opening with a path, so a message like /etc/hosts needs a line or /sandbox/cli/src/ui/ConnectApp.tsx:651 is wrong can no longer be sent at all — there is no escape spelling — where before this commit only /stop, /exit and /quit were intercepted.
Verified end to end: the line is refused as an unknown command and never reaches api.sessions.sendMessage. Matching a bare /word keeps the typo guard (/stpo is still refused) without swallowing paths.
| export function isCommandInput(text: string): boolean { | |
| return text.startsWith('/') | |
| } | |
| export function isCommandInput(text: string): boolean { | |
| // A bare `/word` only: a line that opens with a PATH (`/etc/hosts is wrong`) | |
| // is prose bound for the agent, and refusing it leaves no way to send it. | |
| return /^\/[a-z][a-z0-9-]*(\s|$)/i.test(text) | |
| } |
| const next = key.upArrow ? menuAt - 1 : menuAt + 1 | ||
| // Wraps, because the list is short enough that walking off one end | ||
| // meaning "go to the other" is faster than reversing direction. | ||
| setMenuIndex((next + menu.length) % menu.length) |
There was a problem hiding this comment.
The highlight wraps over menu.length but the menu only renders menu.slice(0, menuRows), so on a short terminal ↑ selects a row that is not on screen — wrap over the rows actually rendered.
At 9 terminal rows menuRows is 3 of 4 matches: typing / then one ↑ leaves no ▶ visible anywhere in the frame, and tab then completes /exit — a command the user never saw highlighted. Confirmed in a fake-TTY render.
| const next = key.upArrow ? menuAt - 1 : menuAt + 1 | |
| // Wraps, because the list is short enough that walking off one end | |
| // meaning "go to the other" is faster than reversing direction. | |
| setMenuIndex((next + menu.length) % menu.length) | |
| const next = key.upArrow ? menuAt - 1 : menuAt + 1 | |
| // Wraps, because the list is short enough that walking off one end | |
| // meaning "go to the other" is faster than reversing direction — | |
| // over the rows RENDERED (menuRows), so the highlight is always one | |
| // you can see. | |
| const shown = Math.max(1, Math.min(menu.length, menuRows)) | |
| setMenuIndex((next + shown) % shown) |
| // The ✦ rides the GUTTER, like every other row's mark. Baked into the text | ||
| // span it sits one gutter-width right of every glyph below it, which reads | ||
| // as a stray indent on the app's very first line. | ||
| gutter: { text: '✦', dim: true }, | ||
| spans: [{ text: 'Connected to ellipsis.dev', bold: true }], |
There was a problem hiding this comment.
Giving the opening line a gutter moves the ▶ selection marker onto it, contradicting the comment three lines above: markerRowId (line 1168) picks the selected block's first row with a gutter glyph, which is now :hdr.
Same render at 3038ecc vs 041f426, sandbox block highlighted in the ctrl+r browser: before, ✦ Connected to ellipsis.dev / ▶ Session ready!; after, ▶ Connected to ellipsis.dev (the ✦ is replaced) / ✓ Session ready!. The headline can no longer show the selection, and the block's own glyph disappears while selected.
The input is the last child of a pane that clips its overflow, so a terminal too short for the heading and fact above it took the difference out of the input's bottom padding: the tint stopped flush against the prompt, leaving a box with a lid and no floor. The heading and fact now sit in their own shrinkable box and yield first, and the input refuses to shrink at all. Where the rows genuinely are not there, the padding goes from both edges rather than just the bottom one: a box padded on top and open underneath looks broken, where a box with no padding just looks tight.
…ns off Ink treats CI as non-interactive (is-in-ci), and a non-interactive render buffers everything into one final frame: no erase sequences, no repaints, no <Static> flush as it happens. Both harnesses exist to measure exactly that difference — a row written once and owned by the terminal versus one rewritten every frame — so under CI they were asserting against a single frame and failing. They now pass interactive: true, the same flag runConnect pins for the same reason. Verified with CI=true under both vitest and bun.
There was a problem hiding this comment.
Caution
Changes requested ❌ — 2 issues
Incrementally reviewed 22810a5 in 10 minutes, 17 seconds.
- Reviewed
1commit with56lines of code in1file - Ran
1review agent producing2comments where2were posted - This pipeline runs no gatekeeper, so findings are posted as written.
- View full details on ellipsis.dev
This review was created by . You can tag
@ellipsis in this pull request.
| {error && <Text color={theme.error}> ✗ {error}</Text>} | ||
| {starting && <Text color={theme.muted}> ✻ Starting session…</Text>} |
There was a problem hiding this comment.
The notice rows are the only shrinkable siblings left, so on short panes yoga squeezes them to zero height and they overprint the heading/fact instead of the decoration yielding — wrap each in a flexShrink={0} box.
The restructure made the decoration box shrinkable and the input rigid, but left these two Texts at ink's default flexShrink=1, so whenever the pane overflows yoga takes a proportional bite out of the 1-row notice too. Rendering the real pane against an 80-col fake TTY: at pane height 9 (15-row terminal) a start prints the single line ' ✻ Starting session… What are we shipping today?' — notice and heading collided on one row; at 11 it collides with the fact line; at 8 (14-row terminal) the notice is dropped entirely, so a failed start shows no error at all. Adding flexShrink={0} to both notices makes heights 7–18 render cleanly in the same harness. One more row is needed for it: inputPad (line 1107) reserves only PICKER_ROWS.length + 1 + 2, so at pane height 8 the now-rigid notice pushes the input's bottom pad off the clipped pane — reserving + (error || starting ? 1 : 0) closes it.
| {error && <Text color={theme.error}> ✗ {error}</Text>} | |
| {starting && <Text color={theme.muted}> ✻ Starting session…</Text>} | |
| {error && ( | |
| <Box flexShrink={0}> | |
| <Text color={theme.error}> ✗ {error}</Text> | |
| </Box> | |
| )} | |
| {starting && ( | |
| <Box flexShrink={0}> | |
| <Text color={theme.muted}> ✻ Starting session…</Text> | |
| </Box> | |
| )} |
| // the difference out of the input's bottom edge — which reads as a box | ||
| // with a top pad and no bottom one, its tint stopping flush against the | ||
| // prompt. The squeeze belongs on the spacers and the fact above instead. | ||
| flexShrink={0} |
There was a problem hiding this comment.
When the input needs more rows than the pane has, the unshrinkable input now overflows into the pane's overflow="hidden" clip and the prompt line is the row that disappears.
The input is the last child, so the clip lands on its bottom. Rendered against a fake TTY: at pane height 4 (the max(4, …) floor, i.e. a ≤10-row terminal) only the three picker rows show and the '▶ Start a cloud session…' row is gone, while the pre-change layout still drew it; likewise with a picker open at pane heights 8 and 10 (≤16-row terminal), where dropdownCapacity's Math.max(3, height - 12) floor makes the open input ~12 rows tall regardless of pane height. You then type into a field that is not on screen. Clamping dropdownCapacity to what the pane can actually hold (and letting the input's pad go to 0 before its rows do) keeps the prompt visible.
Summary
<Static>), so wheel/trackpad scrolling, select/copy and clickable links are the terminal's own rather than app reimplementations. Only the unsettled tail, the composer and the meta line repaint.ctrl+ropens the transcript on the alternate screen: the windowed view, with tool-run folding, arrow-key entry nav and app-read wheel scrolling.escrestores the chat's screen untouched.ctrl+j(still reachable withesc/↓out of the chat). It could not stay pinned under the chat: rows printing into scrollback run straight through anything pinned around them. It now gets the whole terminal instead of a fixed handful of rows.suspendTerminal, which erases the current frame and forces a clean redraw with its frame bookkeeping reset. Writing1049h/lbehind ink's back leaves it diffing against a frame that is no longer on screen.paneWidth/paneHeight/topPad/hideMetaLine, the persistent header band above the chat, thectrl+smouse-capture toggle (the chat never captures, the browser always does), and the now-meaninglesssessionBar.rowssetting.Notes
Two constraints drove the shape and are worth knowing before touching this code:
<Static>printsitems.slice(printedCount)and re-syncs that count fromitems.length, so the list may only grow and its existing rows may never change. Flushed rows are therefore held in an append-only ref keyed by entry, not re-sliced fromallRowseach frame — a re-slice reprints the whole transcript when the list shrinks (which withholding the flush for the alt screen does), and reprints again on resize becauseallRowsre-wraps. Consequence: printed rows do not reflow when the terminal is resized. Claude Code's scrollback has the same artifact.Test plan
npm run typechecknpx vitest run— 435 tests, 25 filessettledItemKeys,settledRowCount) intest/connect-app.test.tstest/scrollback.test.ts:<Static>prints each row once; documents the reprint-on-shrink behavior the append-only ref avoids;useAltScreenenters/restores, writes nothing to a non-TTY, and restores on exit-while-openConnectAppagainst a fake TTY,test/connect-render.test.ts: settled rows written once across forced repaints while live rows are rewritten; the session-break rule; no mouse capture armed in the chatctrl+r/ctrl+jandescbackagent session connect <id> --no-inputpiped to a file still streams framesImportant
Moves the settled chat transcript into the terminal's real scrollback (via ink's
<Static>), enabling native wheel/trackpad scrolling, text selection, and clickable links without requiring mouse capture or app reimplementations.ctrl+r). Opens a windowed view on the alternate buffer with tool folding, arrow-key entry navigation, and app-controlled scrolling.escrestores the primary buffer and chat untouched.ctrl+j). The session list moves from a pinned band (incompatible with scrollback rows) to a full-screen picker on the alternate buffer.enteropens a session,escreturns./opens a command menu; typing narrows commands by prefix (/stofor/stop,/trafor/transcript); up/down walk the list, tab completes, enter submits. Available commands:/stop,/transcript,/sessions, and/exit(aliased/quit). Replaces the previousctrl+r/ctrl+jkeyhints.paneWidth/paneHeight/topPadprops).scrollbackBreakreplaceshideMetaLineto print a separator when switching between chats.sessionBar.rowscap (session picker takes full terminal height) andctrl+smouse-capture toggle (no longer needed with native scrolling).This description was created by
for 041f426. It will automatically update as commits are pushed.