Skip to content

ui: print the transcript into the terminal's scrollback so the trackpad scrolls it - #111

Merged
hbrooks merged 5 commits into
mainfrom
ui/scrollback-transcript
Aug 19, 2026
Merged

ui: print the transcript into the terminal's scrollback so the trackpad scrolls it#111
hbrooks merged 5 commits into
mainfrom
ui/scrollback-transcript

Conversation

@hbrooks

@hbrooks hbrooks commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

  • The chat's settled transcript is printed into the terminal's real scrollback (ink <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+r opens the transcript on the alternate screen: the windowed view, with tool-run folding, arrow-key entry nav and app-read wheel scrolling. esc restores the chat's screen untouched.
  • The session list becomes a full-screen picker on ctrl+j (still reachable with esc / 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.
  • Both alternate-screen views hop buffers through ink's suspendTerminal, which erases the current frame and forces a clean redraw with its frame bookkeeping reset. Writing 1049h/l behind ink's back leaves it diffing against a frame that is no longer on screen.
  • Removals, since nothing renders the chat in a rectangle any more: paneWidth/paneHeight/topPad/hideMetaLine, the persistent header band above the chat, the ctrl+s mouse-capture toggle (the chat never captures, the browser always does), and the now-meaningless sessionBar.rows setting.

Notes

Two constraints drove the shape and are worth knowing before touching this code:

  • A flushed row is frozen. <Static> prints items.slice(printedCount) and re-syncs that count from items.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 from allRows each 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 because allRows re-wraps. Consequence: printed rows do not reflow when the terminal is resized. Claude Code's scrollback has the same artifact.
  • The flush point is per message, not per turn. An entry is final once the agent starts the next message, because until then a collapsed "Ran N tool calls" fold can still grow under it. That keeps the live frame about one message tall during a long turn.

Test plan

  • npm run typecheck
  • npx vitest run — 435 tests, 25 files
  • New pure-function coverage for the flush boundary (settledItemKeys, settledRowCount) in test/connect-app.test.ts
  • New offline render harness, test/scrollback.test.ts: <Static> prints each row once; documents the reprint-on-shrink behavior the append-only ref avoids; useAltScreen enters/restores, writes nothing to a non-TTY, and restores on exit-while-open
  • New end-to-end render of the real ConnectApp against 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 chat
  • Manual: scroll a long conversation with the trackpad, select and copy text out of it, click a session link, then ctrl+r / ctrl+j and esc back
  • Manual: agent session connect <id> --no-input piped to a file still streams frames

Important

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.

  • Scrollback mode (default). Settled rows print once into the scrollback and never repaint, keeping the live frame (unsettled tail + composer + meta) small and fast. This trades away repaintability for native terminal affordances — a message can no longer reflow or change once flushed.
  • Alternate-screen transcript browser (ctrl+r). Opens a windowed view on the alternate buffer with tool folding, arrow-key entry navigation, and app-controlled scrolling. esc restores the primary buffer and chat untouched.
  • Alternate-screen session picker (ctrl+j). The session list moves from a pinned band (incompatible with scrollback rows) to a full-screen picker on the alternate buffer. enter opens a session, esc returns.
  • Slash commands with autocomplete. Typing / opens a command menu; typing narrows commands by prefix (/sto for /stop, /tra for /transcript); up/down walk the list, tab completes, enter submits. Available commands: /stop, /transcript, /sessions, and /exit (aliased /quit). Replaces the previous ctrl+r/ctrl+j keyhints.
  • Removed pane hosting. The chat now always owns the terminal outright (no paneWidth/paneHeight/topPad props). scrollbackBreak replaces hideMetaLine to print a separator when switching between chats.
  • Removed painted surfaces except composer background. Since flushed rows carry their background into scrollback where nothing will ever repaint them, stale fills survive resizes. Background color moved to glyphs and foreground only (terminal's background shows through), except the composer's panel which lives in the live frame and repaints every frame.
  • Removed sessionBar.rows cap (session picker takes full terminal height) and ctrl+s mouse-capture toggle (no longer needed with native scrolling).
  • Flush boundary is per-message, not per-turn: the moment an agent starts a new message, the previous one and its whole tool run freeze, keeping the live region roughly one message tall during long turns.

This description was created by Ellipsis for 041f426. It will automatically update as commits are pushed.

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

@ellipsis-dev ellipsis-dev 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.

Important

Looks good to me! 👍

Reviewed afdbb8a in 10 minutes, 51 seconds.
  • Reviewed 1 commit with 1365 lines of code in 14 files
  • Ran 1 review agent producing 0 comments where 0 were posted
  • This pipeline runs no gatekeeper, so findings are posted as written.
  • View full details on ellipsis.dev

This review was created by Ellipsis. 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.

@ellipsis-dev ellipsis-dev 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.

Caution

Changes requested ❌ — 1 issue

Incrementally reviewed 3038ecc in 6 minutes, 41 seconds.
  • Reviewed 1 commit with 446 lines of code in 7 files
  • Ran 1 review agent producing 1 comment where 1 was posted
  • This pipeline runs no gatekeeper, so findings are posted as written.
  • View full details on ellipsis.dev

This review was created by Ellipsis. You can tag @ellipsis in this pull request.

Comment thread test/connect-render.test.ts Outdated
Comment on lines +219 to +229
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;')

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.

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.

Suggested change
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.

@ellipsis-dev ellipsis-dev 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.

Caution

Changes requested ❌ — 4 issues

Incrementally reviewed 041f426 in 10 minutes, 19 seconds.
  • Reviewed 1 commit with 505 lines of code in 6 files
  • Ran 1 review agent producing 4 comments where 4 were posted
  • This pipeline runs no gatekeeper, so findings are posted as written.
  • View full details on ellipsis.dev

This review was created by Ellipsis. You can tag @ellipsis in this pull request.

Comment thread src/ui/ConnectApp.tsx
Comment on lines +653 to +655
if (!command) {
setNotice(`✗ no such command: ${text.split(/\s/)[0]} · type / to see them`)
return

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.

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.

Suggested change
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

Comment thread src/ui/commands.ts
Comment on lines +33 to +35
export function isCommandInput(text: string): boolean {
return text.startsWith('/')
}

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.

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.

Suggested change
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)
}

Comment thread src/ui/ConnectApp.tsx
Comment on lines +1262 to +1265
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)

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.

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.

Suggested change
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)

Comment thread src/ui/ConnectApp.tsx
Comment on lines +1771 to +1775
// 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 }],

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.

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.
@hbrooks
hbrooks merged commit 802e5a8 into main Aug 19, 2026
1 check passed

@ellipsis-dev ellipsis-dev 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.

Caution

Changes requested ❌ — 2 issues

Incrementally reviewed 22810a5 in 10 minutes, 17 seconds.
  • Reviewed 1 commit with 56 lines of code in 1 file
  • Ran 1 review agent producing 2 comments where 2 were posted
  • This pipeline runs no gatekeeper, so findings are posted as written.
  • View full details on ellipsis.dev

This review was created by Ellipsis. You can tag @ellipsis in this pull request.

Comment thread src/ui/SessionsApp.tsx
Comment on lines 1152 to 1153
{error && <Text color={theme.error}> ✗ {error}</Text>}
{starting && <Text color={theme.muted}> ✻ Starting session…</Text>}

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.

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.

Suggested change
{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>
)}

Comment thread src/ui/SessionsApp.tsx
// 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}

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.

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.

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