From afdbb8a62c4d3eafcdc816a8812bf5adee7af5b1 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 19 Aug 2026 16:55:56 -0400 Subject: [PATCH 1/5] ui: print the transcript into the terminal's scrollback so the trackpad 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. --- README.md | 8 +- src/commands/connect.ts | 18 +- src/lib/config.ts | 9 +- src/lib/sessions.ts | 9 +- src/ui/ConnectApp.tsx | 374 ++++++++++++++++++++++++++---------- src/ui/SessionsApp.tsx | 217 +++++++++++++-------- src/ui/altScreen.ts | 43 +++++ src/ui/launch.tsx | 9 +- src/ui/transcriptRows.ts | 48 +++++ test/config.test.ts | 8 +- test/connect-app.test.ts | 53 +++++ test/connect-render.test.ts | 222 +++++++++++++++++++++ test/scrollback.test.ts | 141 ++++++++++++++ test/sessions.test.ts | 5 +- 14 files changed, 934 insertions(+), 230 deletions(-) create mode 100644 src/ui/altScreen.ts create mode 100644 test/connect-render.test.ts create mode 100644 test/scrollback.test.ts diff --git a/README.md b/README.md index 7f1643b..5ee205c 100644 --- a/README.md +++ b/README.md @@ -162,13 +162,12 @@ is migrated on first use — your existing login becomes a host named for its AP base. The config file also carries UI preferences. `"sessionBar"` scopes the session -list at the bottom of the interactive UI: +list, which the interactive UI opens as a full screen with `ctrl+j`: ```json { "sessionBar": { "hidden": false, - "rows": 5, "days": 7, "repo": "cwd", "statuses": "all", @@ -177,9 +176,8 @@ list at the bottom of the interactive UI: } ``` -`hidden` drops the bar entirely and gives its rows to the chat window. `rows` is -how many sessions it lists (a short terminal shows fewer). `days` hides sessions -that have not moved in that long; `0` means no age cutoff. `repo` is `"cwd"` to +`hidden` drops the list entirely, so the chat never hands focus to it. `days` +hides sessions that have not moved in that long; `0` means no age cutoff. `repo` is `"cwd"` to list only sessions on the repository your shell is in, or `"any"` for all of them. `statuses` is `"unfinished"` to leave out the sessions that finished, errored, or were stopped, or `"all"` to keep them. `sources` lists only sessions diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 9a23913..aaab961 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -139,14 +139,11 @@ export async function runConnect( // Written by the app when it exits because the conversation closed (terminal; // nothing left to reconnect to), so the detach sign-off below stays honest. const exitState = { closed: false } - // Start the app at the top of a fresh window: newlines scroll whatever is on - // screen (the shell prompt, anything a caller printed) into scrollback, then - // the cursor homes to row 1. Without this the first paint begins mid-screen, - // overflows the window, and the app's opening lines (the sandbox startup - // notes) end up stranded above the fold. - if (process.stdout.isTTY) { - process.stdout.write('\n'.repeat(process.stdout.rows ?? 24) + '\x1b[H') - } + // No screen-clearing dance here any more: the app prints its settled + // transcript INTO this terminal's scrollback (see ConnectApp's scrollback + // mode), so the conversation grows down the terminal from wherever the shell + // prompt left off, exactly like ordinary command output. Homing the cursor + // would throw away the scrollback the app now relies on. const app = render( React.createElement(ConnectApp, { api: client, @@ -204,9 +201,8 @@ export async function runConnect( if (canSend && !exitState.closed) { // The session keeps running after a detach; hand back the exact command - // that re-opens this conversation. No leading newline: the single row this - // line scrolls is absorbed by the app's top padding (see ConnectApp), so - // the sign-off never scrolls the app's first content line out of the window. + // that re-opens this conversation. It prints under the app's last frame, + // like any other command's parting line. console.log(`resume with: agent session connect ${sessionId}`) } } diff --git a/src/lib/config.ts b/src/lib/config.ts index cbebb76..bd7ce13 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -35,10 +35,8 @@ export interface Host { // How the interactive UI's session bar is scoped. Every field is optional; a // missing one takes the SESSION_BAR_DEFAULTS value below. export interface SessionBarConfig { - // Drop the bar entirely, giving its rows to the chat window. + // Drop the session list entirely: the chat then never hands focus to it. hidden?: boolean - // How many session rows the bar shows (a short terminal shows fewer). - rows?: number // Only sessions that moved in the last N days; 0 means no age cutoff. days?: number // "cwd" lists only sessions on the repository the shell is in, falling back @@ -243,7 +241,6 @@ export const SESSION_BAR_DEFAULTS: Required> & sources: string[] | undefined } = { hidden: false, - rows: 5, days: 7, repo: 'cwd', statuses: 'all', @@ -266,10 +263,6 @@ export function sessionBar(): ResolvedSessionBar { : undefined return { hidden: raw.hidden === true, - rows: - typeof raw.rows === 'number' && isFinite(raw.rows) && raw.rows >= 1 - ? Math.floor(raw.rows) - : SESSION_BAR_DEFAULTS.rows, days: typeof raw.days === 'number' && isFinite(raw.days) && raw.days >= 0 ? Math.floor(raw.days) diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 426bfe1..05c39cd 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -203,7 +203,6 @@ export function mergeSidebarSessions( // still show it. export function sessionBarQuery( bar: { - rows: number days: number repo: 'cwd' | 'any' statuses: 'all' | 'unfinished' @@ -213,9 +212,9 @@ export function sessionBarQuery( ): ListAgentSessionsQuery { const query: ListAgentSessionsQuery = { author_id: context.authorId ?? undefined, - // Enough rows to band and scroll past the visible window, without paying - // for a page nobody scrolls to. - limit: Math.max(SESSION_BAR_FETCH, bar.rows), + // Enough rows to band and scroll past a screenful, without paying for a page + // nobody scrolls to. + limit: SESSION_BAR_FETCH, } if (bar.days > 0) query.days = bar.days if (bar.repo === 'cwd' && context.detectedRepo) query.repo = context.detectedRepo @@ -224,7 +223,7 @@ export function sessionBarQuery( return query } -// How many rows the bar fetches to fill its window from. +// How many rows the session list fetches to fill its screen from. export const SESSION_BAR_FETCH = 50 // Attention transitions: a session that WAS in flight and now waits for a diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index e9a6d4a..e2ef62f 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -6,7 +6,7 @@ import React, { useState, useSyncExternalStore, } from 'react' -import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink' +import { Box, Static, Text, useApp, useInput, useStdin, useStdout } from 'ink' import { streamSession, sessionStatusWord, @@ -34,9 +34,10 @@ import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' -import { fitLines } from '../lib/markdown' +import { fitLines, visibleWidth } from '../lib/markdown' import { SELECTION_GLYPH } from '../lib/sessions' import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' +import { useAltScreen } from './altScreen' import { VERSION } from '../lib/constants' import { activityRows, @@ -56,6 +57,8 @@ import { padPanelBlocks, pendingMessageRows, rowViewport, + settledItemKeys, + settledRowCount, snapAnchorForEntry, spacerRow, spanColor, @@ -71,6 +74,22 @@ import { // what you send. Rendering shape lives in @ellipsis-dev/sdk/store (pure); this // component owns the data flow, the composer, and the colours. // +// TWO VIEWS of the same transcript, because they want opposite things from the +// terminal: +// +// * THE CHAT (resting). Settled rows are handed to , which prints them +// ONCE into the terminal's own scrollback and never repaints them. That is +// what makes wheel/trackpad scrolling, select/copy and clickable links work +// natively — they are the terminal's, not reimplementations. The price is +// that a printed row is frozen: it cannot re-wrap, re-fold, or take a +// selection marker. Only the unsettled tail plus the footer repaint. +// * THE BROWSER (ctrl+r). A windowed view of the whole conversation on the +// ALTERNATE screen, where rows can be repainted: folding tool runs open and +// shut, walking entries with ↑/↓, app-read wheel scrolling. esc restores the +// chat's screen exactly as it was. +// +// `windowed` is the flag that says which one this frame is painting. +// // Data flow: ONE SessionTranscriptStore (pre-seeded by the caller with the // stored records + session, so the first paint is instant) is fed by the // SDK's streamSession — records arrive PUSHED as records_append frames, the @@ -110,28 +129,21 @@ export interface ConnectAppProps { // conversation closed, so the caller skips the "detached — still running" // sign-off (the session is not still running). exitState?: { closed: boolean } - // ---- pane hosting (the multi-session UI) ---- - // When set, the app renders inside a fixed-size pane instead of sizing to - // the terminal: wrap math and the viewport budget use these instead of - // stdout's rows/columns. - paneWidth?: number - paneHeight?: number - // Blank rows above the first line, at MOST: the padding is the layout's - // give, so a pane too short for it gets fewer (or none). The solo app keeps - // TOP_PAD's slack against terminal row-accounting quirks; a pane host owns - // its own edges. - topPad?: number - // Whether this pane owns the keyboard. The host keeps exactly one input - // handler active (sidebar or chat); default true for the solo app. + // ---- hosting (the multi-session UI) ---- + // Whether the app owns the keyboard. The host keeps exactly one input handler + // active (session picker or chat); default true for the solo app. focused?: boolean - // Leave the pane and hand focus to the host's session nav (the bar below - // the composer). Fired by esc with nothing open (no panel, no transcript - // nav) and by ↓ at the bottom edge (the composer's last line, or a - // watch-only follow). Absent in the solo app, where neither leaves. + // Hand focus to the host's session picker. Fired by ctrl+j, by esc with + // nothing open (no browser, no transcript nav), and by ↓ at the bottom edge + // (the composer's last line, or a watch-only follow). Absent in the solo app, + // which has no picker to open. onFocusNav?: () => void - // Drop the bottom meta line (status · cost · model · id · version): the - // host renders it in its own header instead. - hideMetaLine?: boolean + // Print a header above this session's first flushed row. Set by the host when + // this chat REPLACES another session's chat: both print into the same + // scrollback, one under the other, so without a break the second reads as a + // continuation of the first. Absent for the first chat of the process, which + // has nothing above it. + scrollbackBreak?: boolean // Terminal outcomes, reported to the host INSTEAD of exiting the app: // 'closed' = the conversation closed; 'preflight' = the session died // before it became connectable; 'ended' = a watch-only stream finished. @@ -147,12 +159,6 @@ function isWorkingStatus(status: string): boolean { return ['scheduled', 'starting', 'working', 'retrying'].includes(status) } -// Blank rows rendered above the app's first line: one of visual breathing room -// above the ✦ startup header, plus one of sacrificial slack — see the termRows -// comment in ConnectApp for what the slack absorbs. Deliberately thin: every -// row here is a row the conversation doesn't get. -const TOP_PAD = 2 - // Text rows inside the composer panel, before its 1-cell pad above and below. // One row: the input grows as you type past it, and the rows it isn't using // belong to the conversation. @@ -166,6 +172,10 @@ const COMPOSER_PAD_X = 2 // per tick makes a trackpad feel like it's dragging through treacle. const WHEEL_ROWS = 3 +// The startup block's entry key — it is one block, not a transcript item, so it +// owns a fixed key rather than a feed_seq one. +const SANDBOX_KEY = 'sandbox' + // Half the period of the live ⏺ pulse: the glyph dims for this long, then // brightens for this long. ~1.4s a cycle — slow enough to read as breathing // rather than flashing, and it lands off the 1s duration tick so the two @@ -191,14 +201,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const { isRawModeSupported } = useStdin() const { stdout } = useStdout() - // Terminal height, tracked across resizes, so the app fills the whole - // window Claude Code-style: composer + meta pinned to the bottom edge (one - // row below is left for the shell cursor), the transcript growing through - // the space between, and TOP_PAD blank rows of padding above the first - // line. The padding is deliberate slack: terminals that consume an extra - // row (observed in practice) and the caller's post-exit sign-off line - // ("resume with: …") each scroll one padding row into scrollback instead - // of the app's first line, so the sandbox startup notes stay visible. + // Terminal size, tracked across resizes. It bounds the LIVE region — the + // unsettled tail, the composer and the meta line — rather than sizing a window + // the whole conversation has to fit in: the settled transcript above has been + // handed to the terminal, which is what makes the wheel work, and it is the + // terminal's business how tall that is now. const [termRows, setTermRows] = useState(stdout?.rows ?? 24) const [termCols, setTermCols] = useState(stdout?.columns ?? 80) useEffect(() => { @@ -213,15 +220,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } }, [stdout]) - // Hosted-in-a-pane vs owning the terminal (the solo `connect`). A pane host - // fixes the size and owns the window edges (no shell-row or sign-off slack), - // so hosted mode drops the solo app's -1 bottom row and defaults topPad to 1. - const hosted = props.paneWidth != null || props.paneHeight != null - const rows = props.paneHeight ?? termRows - const cols = props.paneWidth ?? termCols - const topPad = props.topPad ?? (hosted ? 1 : TOP_PAD) + // The app always owns the terminal now (there is no pane host left), so the + // resting view is SCROLLBACK: the settled transcript is flushed ONCE into the + // terminal's own scrollback () and only the live tail + composer + // repaint. Wheel/trackpad scrolling, select/copy and clickable links are then + // the terminal's own, which is the whole point. + const rows = termRows + const cols = termCols const focused = props.focused ?? true - const bottomSlack = hosted ? 0 : 1 + // A row left for the shell cursor and the caller's sign-off line. + const bottomSlack = 1 // Terminal outcomes: reported to the host when there is one (the app stays // mounted; the host decides what to show), otherwise exit the Ink render. @@ -279,13 +287,36 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // wheel and ↑/↓ move it a row at a time; the highlight snaps it so the // selected entry comes into frame. const [scrollAnchor, setScrollAnchor] = useState(null) - // Whether the terminal's mouse reporting is armed (wheel/trackpad scrolls - // the transcript). Capturing the mouse steals native text selection and - // clickable links, so it starts off — the terminal keeps the mouse for - // clicking links and copy/paste, and ctrl+s arms it for wheel scrolling - // (the terminal's own bypass, shift-drag or option-drag in iTerm2, works - // either way). - const [mouseCapture, setMouseCapture] = useState(false) + // The transcript BROWSER: a windowed view of the whole conversation, opened + // over the alternate screen with ctrl+r and closed with esc. It exists because + // the scrollback view gives up the things a repaintable window can do — + // folding tool runs open and shut, walking entries with ↑/↓, re-wrapping on + // resize — so those move here instead of disappearing. + // + // While it is open the flush is withheld: a row flushed onto the alt + // screen would die with that buffer, so the primary buffer would come back + // missing exactly the rows that settled while you were reading. Held back, + // they flush on the way out. + const [windowed, setWindowed] = useState(false) + useAltScreen(windowed) + // Everything ↑/↓ can land on, read through a ref so opening the browser can + // select the newest entry without the selection effect re-firing every time a + // record lands and dragging the highlight back down the transcript. + const navKeysRef = useRef([]) + // Opening the browser lands on the newest entry, so →/← have something to + // open the moment the screen appears. Leaving drops the selection, the scroll + // position and the expand toggle: re-opening starts at the bottom of the + // conversation rather than wherever it was parked a conversation ago. + useEffect(() => { + if (windowed) { + const keys = navKeysRef.current + if (keys.length > 0) setNavKey(keys[keys.length - 1]) + return + } + setNavKey(null) + setScrollAnchor(null) + setExpanded(false) + }, [windowed]) // Messages you've sent that the server hasn't acknowledged yet — shown // IMMEDIATELY as dim rows at the bottom of the transcript, so a send always // appears in the chat the moment you hit enter. From the first @@ -652,27 +683,29 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { [api, exit, pump, sessionId], ) - // The composer renders whenever sending is possible. The keyboard handler - // is gated on pane focus (the host keeps exactly one handler active) but - // NOT on sendability: a hosted watch-only chat (closed conversation, - // single-shot session) still navigates, scrolls, and hands focus back with - // esc — its composer-editing keys are ignored below. The solo watch-only - // follow keeps today's no-input behavior. - const composerVisible = canSend && isRawModeSupported - const inputActive = (composerVisible || (hosted && isRawModeSupported)) && focused - - // Mouse reporting (SGR), so wheel/trackpad scroll reaches the app as input - // instead of scrolling the terminal's (empty) scrollback. Capturing the - // mouse steals native text selection, so ctrl+s toggles the capture off - // for normal select/copy (the terminal's shift/option-drag bypass works - // while armed, too). Turned off again on unmount. + // The composer renders whenever sending is possible. + // The browser drops the composer: it is a reader, and those rows are better + // spent on conversation. The keyboard handler stays active (it owns the + // browser's own keys), and its composer-editing branches are gated below. + const composerVisible = canSend && isRawModeSupported && !windowed + // The browser keeps the keyboard even though it has no composer — it is + // driven entirely by keys, esc included, so gating input on the composer + // would strand you on the alt screen. + const inputActive = (composerVisible || windowed) && focused + + // Mouse reporting (SGR) — the browser's wheel scrolling, and ONLY the + // browser's. It owns the alternate screen, whose scrollback is empty by + // definition, so the wheel is useless there unless the app reads it. The chat + // never arms it: down there the transcript is in the terminal's real + // scrollback, and capturing the mouse would take native scrolling, select/copy + // and clickable links away to reimplement what the terminal just did for free. useEffect(() => { - if (!inputActive || !mouseCapture || !stdout) return + if (!windowed || !inputActive || !stdout?.isTTY) return stdout.write('\u001B[?1000h\u001B[?1006h') return () => { stdout.write('\u001B[?1006l\u001B[?1000l') } - }, [inputActive, mouseCapture, stdout]) + }, [windowed, inputActive, stdout]) // The rendered transcript lines, in order: collapsed (the default) folds // consecutive tool activity into "Ran N …" notices, except the runs under a @@ -726,20 +759,36 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // pane owns the keyboard, watch-only follows included (nothing to stop there, // but ctrl+c still has to be the way out). const ctrlCArmed = useCtrlCQuit( - isRawModeSupported && focused && (composerVisible || hosted || !hasHost), + isRawModeSupported && focused && (composerVisible || windowed || !hasHost), () => { if (working && canSend) submit('/stop') }, ) // The notice bar doubles as the ctrl+c prompt: armed, it says what a second // press does, so the quit is never a surprise. - const shownNotice = ctrlCArmed ? CTRL_C_QUIT_HINT : notice + // The browser is for reading, not sending: it drops the composer for the rows + // (a whole screen of conversation is the point of opening it) and says so on + // the notice line, which is where the app's one line of transient guidance + // already lives. + const browserNotice = '↑↓ scroll · → open · ← close · ctrl+r expand all · esc back to the chat' + // With nothing else to say, the chat names the screens it can open — neither + // is visible from here, so without this they are undiscoverable. Only where + // the keyboard actually reaches us: a headless `--no-input` follow (piped into + // a script or an agent) would otherwise advertise keys nothing can press. + const restingHint = isRawModeSupported + ? `ctrl+r transcript${props.onFocusNav ? ' · ctrl+j sessions' : ''}` + : null + const shownNotice = windowed + ? browserNotice + : ctrlCArmed + ? CTRL_C_QUIT_HINT + : (notice ?? restingHint) const { viewBudget, padRows, composerRows, noticeRows } = useMemo(() => { // Both wrapping parts of the footer are measured as the rows they will // actually OCCUPY, not as the newlines they contain: a notice ("stream // error: …") and a typed paragraph both wrap, and counting either as one // row means the footer quietly outgrows the space reserved for it. - const fixed = (props.hideMetaLine ? 0 : 1) + 1 /* footer margin */ + const fixed = 1 /* meta line */ + 1 /* footer margin */ // What the wrapping parts share. Each takes what it needs and yields the // rest, in priority order: the chat window always keeps a row, then the // composer, and the notice gives up its extra rows first (it truncates — @@ -759,18 +808,19 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const wanted = Math.max(COMPOSER_INTERIOR_ROWS, typedRows) + 2 const composerRows = composerVisible ? Math.max(1, Math.min(wanted, free - 1)) : 0 const forContent = Math.max(1, free - composerRows) - // At least one row of chat: the top padding yields first. - const pad = Math.max(0, Math.min(topPad, forContent - 1)) + // The browser fills the screen it took over, so it keeps a row of top + // padding; the chat is content-sized and grows downward, so it has no window + // edge to protect and takes none. + const pad = windowed ? Math.max(0, Math.min(1, forContent - 1)) : 0 return { viewBudget: forContent - pad, padRows: pad, composerRows, noticeRows } }, [ rows, cols, bottomSlack, - topPad, + windowed, composerVisible, composer.text, shownNotice, - props.hideMetaLine, ]) // The live tail: the in-progress response and the one activity line under @@ -989,6 +1039,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } return out }, [allRows, visible]) + navKeysRef.current = navKeys // The window on screen this frame. A stale anchor (its entry folded away or // scrolled off the record log) falls back to following the bottom. @@ -997,6 +1048,63 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return rowViewport(allRows.length, viewBudget, anchor) }, [allRows, viewBudget, scrollAnchor]) + // ---- the scrollback split ---- + // Rows whose content is FINAL go to : printed once, into the + // terminal's own scrollback, never repainted. Everything after them is the + // live region, repainted each frame. See settledItemKeys for what "final" + // means and why it is decided per message rather than per turn. + // + // The startup block is final once it has settled (it collapses to its bare + // headline then, so the flushed copy is the one that lasts). It sits at the + // top of the list, so nothing below it can flush while it is still moving. + const settledKeys = useMemo(() => { + if (windowed) return new Set() + const keys = settledItemKeys(visible, working) + if (sandboxSettled) keys.add(SANDBOX_KEY) + return keys + }, [windowed, visible, working, sandboxSettled]) + // The rows already handed to , held APPEND-ONLY in a ref, and the + // entries they covered. Both are needed, and neither can be replaced by + // re-slicing allRows each frame: + // + // * prints `items.slice(printedCount)` and re-syncs printedCount + // from items.length. So the list may only GROW, and the rows already in it + // may never change. Hand it a shorter list — which withholding the flush + // for the browser does — and it re-prints everything on the way back. + // * The rows on the terminal are FROZEN TEXT, wrapped at the width they were + // printed at. allRows re-wraps on resize, so a re-slice would hand + // different rows for the same content and print them again. What + // was flushed is history: it is kept verbatim, and a resize re-wraps only + // the live region below. (Claude Code's scrollback has the same artifact — + // printed output does not reflow.) + // + // So an entry is flushed ONCE, keyed by entryKey, and its rows are kept as they + // were built. Seeded with the session break when this chat replaces another + // one's, so the rule prints above the first row of history, not after it. + const flushedRows = useRef( + props.scrollbackBreak ? [sessionBreakRow(sessionId, cols)] : [], + ) + const flushedEntries = useRef>(new Set()) + if (!windowed) { + const settledRows = allRows.slice(0, settledRowCount(allRows, settledKeys)) + const fresh = settledRows.filter((r) => !flushedEntries.current.has(r.entryKey)) + if (fresh.length > 0) { + flushedRows.current = [...flushedRows.current, ...fresh] + for (const row of fresh) flushedEntries.current.add(row.entryKey) + } + } + const staticRows = flushedRows.current + // The live region: everything not yet flushed, capped to the rows the frame + // has for it. Capped from the FRONT (keep the newest) because an over-tall + // live frame scrolls ink's render region and smears stale rows up the + // terminal — and nothing is lost, since these rows flush as they settle. + const liveRows = useMemo(() => { + const rest = allRows.filter((r) => !flushedEntries.current.has(r.entryKey)) + return rest.length > viewBudget ? rest.slice(rest.length - viewBudget) : rest + // flushedEntries is a ref mutated above during this same render, so the + // filter always sees the boundary this frame just committed to. + }, [allRows, viewBudget, staticRows]) + // The one row that wears the ▶ marker: the selected block's FIRST row with a // gutter glyph, since the marker replaces that glyph in place. Only one row // takes it — a block with nested tool activity has a glyph on the call and on @@ -1086,18 +1194,26 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if (m[1] === '64') delta -= WHEEL_ROWS else if (m[1] === '65') delta += WHEEL_ROWS } - if (delta !== 0) scrollByRows(delta) + if (delta !== 0 && windowed) scrollByRows(delta) return } // Page keys scroll the window a frame at a time, from the composer or - // the transcript alike — the fast way through a long conversation. + // the transcript alike — the fast way through a long conversation. In + // the scrollback view the terminal's own page keys do this, over the real + // scrollback, so the app leaves them alone. if (key.pageUp || key.pageDown) { - scrollByRows(key.pageUp ? -view.capacity : view.capacity) + if (windowed) scrollByRows(key.pageUp ? -view.capacity : view.capacity) return } if (key.escape) { - // Modal-first: transcript navigation drops back to the composer, then - // esc leaves the pane. + // Modal-first, outermost modal first: the browser is a screen of its + // own, so esc closes it before anything inside it is considered. + if (windowed) { + setWindowed(false) + return + } + // Transcript navigation drops back to the composer, then esc leaves + // the pane. if (navKey !== null) { setNavKey(null) setScrollAnchor(null) @@ -1109,21 +1225,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { else if (working) submit('/stop') return } + // ctrl+r means "show me everything", and what that takes depends on where + // the transcript lives. In a repaintable window it expands every collapsed + // body and tool fold in place. In the scrollback view the printed rows + // can't be re-folded, so ctrl+r OPENS THE BROWSER — the windowed view of + // the whole conversation on the alt screen — where it can. Inside the + // browser it goes back to being the expand toggle. if (key.ctrl && ch === 'r') { - setExpanded((v) => !v) + if (windowed) setExpanded((v) => !v) + else setWindowed(true) return } - // ctrl+s releases/re-arms the mouse capture: released, the terminal - // gets the mouse back for normal select/copy; armed, wheel/trackpad - // scrolls the transcript. - if (key.ctrl && ch === 's') { - const next = !mouseCapture - setMouseCapture(next) - setNotice( - next - ? 'wheel scrolling on · ctrl+s to release mouse for links/select' - : 'mouse released for links/select · ctrl+s for wheel scrolling', - ) + // ctrl+j opens the session picker. It needs a key of its own now that the + // list is a screen rather than a band you can see: esc and ↓ still reach + // it, but neither says it is there. + if (key.ctrl && ch === 'j' && props.onFocusNav) { + props.onFocusNav() return } if (navKey !== null) { @@ -1205,11 +1322,20 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } return } + // The browser has no composer and no session nav to hand focus to: with + // nothing highlighted yet, ↑/↓ scroll the window a notch (entering the + // ↑ walk above once something is selected). esc — handled at the top — is + // the only way out. + if (windowed) { + if (key.upArrow) scrollByRows(-1) + else if (key.downArrow) scrollByRows(1) + return + } // Below here are the composer's own keys. Watch-only (no composer): // ↑ still enters transcript navigation, ↓ leaves for the session nav; // everything else is inert. if (!composerVisible) { - if (key.upArrow && navKeys.length > 0) { + if (key.upArrow && windowed && navKeys.length > 0) { setNavKey(navKeys[navKeys.length - 1]) setScrollAnchor(null) } else if (key.downArrow && props.onFocusNav) { @@ -1230,10 +1356,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } if (key.upArrow) { // Up inside a multi-line input climbs a line; up on line 1 moves - // focus into the transcript, landing on the newest line. + // focus into the transcript, landing on the newest line. Not in + // the scrollback view: the transcript up there is the terminal's, not + // the app's, so there is nothing to put a highlight on. const up = cursorLineUp(composer.text, composer.cursor) if (up !== null) setComposer((c) => ({ ...c, cursor: up })) - else if (navKeys.length > 0) { + else if (windowed && navKeys.length > 0) { setNavKey(navKeys[navKeys.length - 1]) setScrollAnchor(null) } @@ -1305,11 +1433,31 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // the squeeze resolves inside (the overflow-hidden viewport absorbs it). + {/* The settled transcript, printed once into the terminal's own + scrollback and never repainted — which is what makes the wheel, the + trackpad, and select/copy work natively above the live frame. Empty + (and inert) in a hosted pane, which can't own the scrollback, and + while the browser holds the alt screen. */} + {!windowed && ( + + {(row) => ( + // Flushed rows are frozen: no selection marker, no live tick, no + // pulse — every one of those repaints, and a printed row can't + // repaint. `seconds` is 0 and `pulseOn` true so a row that WAS + // live prints in its settled state. + + )} + + )} {/* Top padding — see the termRows comment: absorbs terminal row- accounting quirks and the post-exit sign-off so the first content line never scrolls out of the window. */} @@ -1328,17 +1476,20 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { rowViewport already emits exactly the rows that fit. */} - {view.showAbove && ( + {windowed && view.showAbove && ( {` ↑ ${view.hiddenAbove} more line${view.hiddenAbove === 1 ? '' : 's'} above`} )} - {allRows.slice(view.start, view.end).map((row) => ( + {(windowed ? allRows.slice(view.start, view.end) : liveRows).map((row) => ( ))} - {view.showBelow && ( + {windowed && view.showBelow && ( {` ↓ ${view.hiddenBelow} more line${view.hiddenBelow === 1 ? '' : 's'} below`} @@ -1440,7 +1591,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} - {!props.hideMetaLine && {metaLine}} + {metaLine} ) @@ -1477,6 +1628,23 @@ export function cursorLineDown(text: string, cursor: number): number | null { return nextStart + Math.min(col, nextLen) } +// The rule printed into scrollback above a session that replaced another in the +// same terminal: two conversations otherwise run together, and the second's +// opening line reads as the first's next turn. Names the session it opens, since +// that is the thing the reader needs to know about the text below it. +function sessionBreakRow(sessionId: string, cols: number): TranscriptRow { + const label = ` ${sessionId} ` + const rule = '─'.repeat(Math.max(0, contentWidth(cols) - visibleWidth(label))) + return { + id: `break:${sessionId}`, + entryKey: `break:${sessionId}`, + spans: [ + { text: label, dim: true }, + { text: rule, dim: true }, + ], + } +} + // One or more SGR mouse reports (\x1b[` all open. +// The multi-session UI — what a bare `agent`, `agent "prompt"`, and `agent +// session connect ` all open. It is a set of SCREENS, not a stack of bands: // -// Focus is modal and esc steps outward: inside the chat esc closes panels, -// then transcript navigation, then lands on the nav list. ↓ at the composer's -// last line reaches the nav too; enter (or esc, or ↑ off the top row) hands it -// back. Exactly one useInput handler is active at a time. +// * the chat (the ConnectApp) owns the terminal outright. Its settled +// transcript is printed into the terminal's real scrollback, so the wheel, +// the trackpad and select/copy are the terminal's own — which is the reason +// for the screen split. Nothing may be pinned above or below it: rows +// scrolling past would run straight through any such band. +// * the session picker (ctrl+j, or esc / ↓ out of the chat) takes over the +// ALTERNATE screen: the full session list, with the chat left untouched on +// the primary buffer behind it. enter opens a session and returns. +// * the transcript browser (ctrl+r, owned by ConnectApp) is the other +// alternate-screen view: the windowed transcript with folding and ↑/↓ nav, +// which the scrollback view cannot do. +// * the new-session composer and the loading placeholder do own their frame, +// so they keep the header band and the canvas inset. +// +// Focus is modal and esc steps outward: inside the chat esc closes the browser, +// then transcript navigation, then opens the picker. Exactly one useInput +// handler is active at a time. // // Liveness: ONE WebSocket — the focused session's, owned by its ConnectApp — // plus a 5s REST poll of the session list for the nav. Transcript stores are @@ -112,10 +117,9 @@ export interface SessionsAppProps { // caveat to show in its chat (watch-only reasons ride connectability). initialConfigName?: string initialNotice?: string - // How the session nav (band 4) is scoped: how many rows it shows and which - // sessions reach it. `hidden` drops the band entirely, giving its rows to - // the chat — focus then never leaves the chat, so esc and ↓ at the bottom - // edge do nothing. Set under "sessionBar" in the config file. + // Which sessions reach the picker. `hidden` drops it entirely — focus then + // never leaves the chat, so esc, ↓ at the bottom edge and ctrl+j do nothing. + // Set under "sessionBar" in the config file. sessionBar: ResolvedSessionBar // Builds the start request for a composer-spawned session (the entry point // owns repository detection and defaults). @@ -238,8 +242,18 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // Both openings start with the main pane focused: a connect lands you in // the conversation, a bare `agent` lands you in the new-session composer. - // 'nav' = the session bar at the bottom owns the keyboard. + // 'nav' = the session picker owns the keyboard, which now means it is OPEN: + // the session list is a screen of its own on the alternate buffer (ctrl+j, or + // ↓/esc out of the chat) rather than a band pinned under every frame. + // + // It moved there because the chat gave up its viewport: the transcript is + // printed into the terminal's real scrollback now, and scrollback is + // all-or-nothing — a band pinned below the chat would be overwritten by the + // rows scrolling past it. A screen of its own also gives the list the whole + // terminal rather than a fixed handful of rows. const [focus, setFocus] = useState<'nav' | 'chat'>('chat') + const navOpen = focus === 'nav' && !hideNav + useAltScreen(navOpen) const [mainPane, setMainPane] = useState( props.initialSessionId ? { type: 'chat', sessionId: props.initialSessionId } : { type: 'new' }, ) @@ -250,6 +264,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // ------------------------------ chat entries ------------------------------ const [entries, setEntries] = useState>(new Map()) + // Sessions whose chat has already printed into this terminal's scrollback. + // The NEXT one to open prints a rule naming itself first, so two conversations + // in one scrollback don't run together (see ConnectApp's scrollbackBreak). + const shownChats = useRef>(new Set()) const [loadError, setLoadError] = useState(null) const loading = useRef(new Set()) @@ -393,7 +411,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { useInput( (ch, key) => { - // The nav is a vertical list: ↑/↓ move the highlight, enter opens the + // The picker is a vertical list: ↑/↓ move the highlight, enter opens the // highlighted session, esc (or ↑ off the top row) returns to the chat. if (key.upArrow || key.downArrow) { const idx = selectable.indexOf(selected) @@ -447,20 +465,30 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // ------------------------------- rendering -------------------------------- - // Band heights: header = blank + title line + rule (3); nav = rule + the - // new-session row + `sessionBar.rows` session rows + hint, or nothing when - // hidden. The chat band gets the rest, and a terminal too short for both - // drops session rows rather than growing the frame past the screen. + // The session picker, open, owns the whole screen: the header, the + // "+ New session" row and the hint line, with every remaining row going to + // sessions. Nothing caps the list any more — the old row cap existed to stop a + // pinned band from eating the chat, and a screen of its own has no such + // conflict. `sessionBar` still scopes WHICH sessions are listed. const headerRows = 3 - const navSessionRows = Math.max(1, Math.min(sessionBar.rows, contentRows - 10)) - const navRows = hideNav ? 0 : 3 + navSessionRows - const chatRows = Math.max(4, contentRows - headerRows - navRows) + const navSessionRows = Math.max(1, contentRows - headerRows - 3) + // The pane height the new-session composer and the loading placeholder get: + // everything under the header. (A live chat is not sized here at all — it owns + // the terminal and prints into its scrollback.) + const paneRows = Math.max(4, contentRows - headerRows) - // ---- band 1: the header ---- + // ---- the header ---- // "ellipsis.dev" pins the left edge as always; the right edge carries the // focused session's live meta (id · model · cost · tokens), derived from // its transcript store so it ticks like the old footer did. With no // session focused the right edge falls back to the who-tag. + // + // It is NOT pinned above the chat any more. A live chat prints its transcript + // into the terminal's scrollback, and a band above a scrolling region is + // simply overwritten by it — the two cannot coexist. So the chat runs + // headerless (its own footer meta line carries the same identity, which is + // why hideMetaLine is dropped below) and the header renders on the screens + // that DO own their frame: the new-session pane and the session picker. const focusedEntry = mainPane.type === 'chat' ? entries.get(mainPane.sessionId) : undefined const metaText = useHeaderMeta(focusedEntry, mainPane, appBase, customerLogin, contentCols - 4) const whoText = props.ghLogin @@ -506,16 +534,16 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { ) - // ---- bands 2+3: the chat window + its composer (one hosted component) ---- + // ---- the main screen: the chat, or the new-session composer ---- let main: React.ReactElement if (mainPane.type === 'new') { main = ( // Full width, no side padding: the prompt input's rules span the // terminal exactly like the header's and the nav's do. - + ) } else { + // Anything already printed below means this chat is arriving under another + // conversation, so it opens with a rule naming itself. Recorded before the + // render so the flag is stable for this mount: the ref is what makes the + // FIRST chat of the process print no break. + const needsBreak = shownChats.current.size > 0 && !shownChats.current.has(mainPane.sessionId) + shownChats.current.add(mainPane.sessionId) main = ( - // The chat window + composer, full width (bands 2 and 3 live inside - // the hosted ConnectApp: transcript above, input box at the bottom). - // overflow=hidden clips a mis-estimated transcript slice instead of - // letting the frame outgrow the terminal (which scrolls Ink's render - // region and smears stale rows on every hop). The meta line is - // hidden here — the header renders it. - - - + // The chat, owning the terminal rather than sitting in a pane: no + // width/height box around it and no pane props, which is what puts + // ConnectApp in its scrollback view (settled transcript printed into the + // terminal's own scrollback, native wheel and select/copy, and ctrl+r for + // the full-screen browser). It renders its own meta line again, since + // there is no header band above it to carry one. + ) } } - // ---- band 4: the session nav ---- - // A vertical list: the pinned new-session row, then `sessionBar.rows` session - // rows (status dot + description + a dim age tag) in sortSidebarSessions - // order — status band, newest-born first — windowed so the highlight parks - // on the second-to-last row and the list scrolls under it. The band's height - // is fixed, so a short list leaves blank rows rather than moving the chat - // above it. + // ---- the session picker ---- + // A vertical list on a screen of its own (the alternate buffer): the pinned + // new-session row, then the session rows (status dot + description + a dim age + // tag) in sortSidebarSessions order — status band, newest-born first — + // windowed so the highlight parks near the bottom and the list scrolls under + // it. It gets the whole terminal now, so the window is only reached by lists + // longer than the screen. const selectedRowIdx = Math.max(0, rows.findIndex((s) => s.id === selected)) const win = navSlice(rows.length, navSessionRows, selectedRowIdx) const navFocused = focus === 'nav' @@ -602,8 +627,8 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // pad is on the band, so every row (and the hint) shares one left edge. @@ -680,22 +705,45 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { ) : ( - {navFocused - ? `↑↓ move · enter open · n new · esc chat · q quit${ - win.end < rows.length ? ` · ${rows.length - win.end} more below` : '' - }` - : '↓/esc: sessions'} + {`↑↓ move · enter open · n new · esc back to the chat · q quit${ + win.end < rows.length ? ` · ${rows.length - win.end} more below` : '' + }`} )} ) + // The picker, open, IS the screen: it took over the alternate buffer, so it + // renders alone (header + list) and the chat is left untouched on the primary + // buffer, waiting behind it. + if (navOpen) { + return ( + + {header} + {nav} + + ) + } + + // A LIVE CHAT owns the terminal outright: no canvas box around it, no header, + // no inset. All three would be frame furniture painted around a region that + // prints into the terminal's scrollback — the rows scrolling past would run + // straight through them. ConnectApp paints its own canvas per row instead. + const chatOwnsScreen = mainPane.type === 'chat' && entries.has(mainPane.sessionId) + if (chatOwnsScreen) return main + + // The remaining screens (the new-session composer, a chat still loading) do + // own their frame, so they keep the canvas, the inset and the header. return ( - // The brand canvas, painted edge to edge: every band sits on it, and the - // composer's panel is the one surface lifted above it. Painting the root - // (rather than letting the terminal's own background show through) is what - // makes the charcoal→panel step read as intentional depth on ANY terminal - // theme instead of only on a dark one. + // The brand canvas, painted edge to edge. Painting the root (rather than + // letting the terminal's own background show through) is what makes the + // charcoal→panel step read as intentional depth on ANY terminal theme + // instead of only on a dark one. {header} {main} - {!hideNav && nav} ) } diff --git a/src/ui/altScreen.ts b/src/ui/altScreen.ts new file mode 100644 index 0000000..5101343 --- /dev/null +++ b/src/ui/altScreen.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react' +import { useApp, useStdout } from 'ink' + +// The alternate screen buffer (DECSET 1049), hand-rolled because ink 7 takes +// `alternateScreen` only as a render()-time option and this app has to hop +// buffers at runtime under ONE live ink instance: the default view lives in the +// primary buffer with its settled transcript flushed to real scrollback, and +// the transcript browser takes over the alt screen and hands the primary buffer +// back untouched on exit. +// +// The switch goes through ink's suspendTerminal, which is the only way to keep +// ink's picture of the screen honest across it: suspend ERASES the current +// frame, and resume forces a full redraw with the frame bookkeeping reset +// (lastOutput/lastOutputHeight). Writing 1049h/l behind ink's back instead +// leaves ink diffing the next frame against one that is no longer on screen, +// which paints the new frame over the wrong rows. Input is paused for the +// duration and restored by resume, so the keyboard survives the hop. +const ENTER = '[?1049h' +const LEAVE = '[?1049l' + +export function useAltScreen(active: boolean): void { + const { suspendTerminal } = useApp() + const { stdout } = useStdout() + // Transitions are serialized: suspendTerminal throws if the terminal is + // already suspended, and two fast toggles (ctrl+r then esc) would otherwise + // overlap. Errors are swallowed — failing to hop buffers must not take the + // session down. + const queue = useRef>(Promise.resolve()) + const hop = (write: string): void => { + if (!stdout?.isTTY) return + queue.current = queue.current + .then(() => suspendTerminal(() => void stdout.write(write))) + .catch(() => {}) + } + useEffect(() => { + if (!active) return + hop(ENTER) + // Also the unmount path: quitting from inside the browser must give the + // primary buffer back rather than leave the shell on the alt screen. + return () => hop(LEAVE) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, stdout, suspendTerminal]) +} diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index 33f7599..dffa953 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -53,11 +53,10 @@ export async function runSessionsUi(options: SessionsUiOptions): Promise { const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) const me = await client.me() - // Start at the top of a fresh window: scroll whatever is on screen into - // scrollback, then home the cursor (same dance as the solo connect). - if (process.stdout.isTTY) { - process.stdout.write('\n'.repeat(process.stdout.rows ?? 24) + '\x1b[H') - } + // No screen-clearing dance: the chat prints its settled transcript into THIS + // terminal's scrollback (see ConnectApp), so the conversation grows down the + // terminal from wherever the shell prompt left off, like ordinary command + // output. Homing the cursor would throw away the scrollback it relies on. const app = render( React.createElement(SessionsApp, { api: client, diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index 2cecaef..85cdd0a 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -530,6 +530,54 @@ export function rowViewport( } } +// ---------------------------------------------------------------- scrollback +// +// In scrollback mode the settled part of the transcript is printed ONCE, into +// the terminal's own scrollback, and never repainted (ink's ). That +// buys native wheel/trackpad scrolling and native select/copy, and it costs +// mutability: a row that has been flushed can't re-wrap, re-fold, or take a +// selection marker. So the flush point has to be a row that CANNOT change +// again, and these two helpers are what decide it. + +// The items whose rows are final. An item's rows can still change for two +// reasons: a collapsed fold ("Ran 2 tool calls") grows as more tool activity +// lands under the same message, and the message that owns the open run is the +// one → can still unfold. So while a turn is in flight the LAST message and +// everything after it stay live, and everything before it is final. With no +// turn in flight nothing can grow, so all of it is final. +// +// Note this is per-MESSAGE, not per-turn: the moment the agent starts a new +// message the previous one and its whole tool run flush together, which is what +// keeps the live frame about one message tall during a long turn. Pure, for +// tests. +export function settledItemKeys( + items: readonly TranscriptItem[], + turnLive: boolean, +): Set { + if (!turnLive) return new Set(items.map((i) => i.key)) + let lastSpeech = -1 + for (const [i, item] of items.entries()) if (!isToolActivity(item)) lastSpeech = i + // A transcript that is nothing but an open tool run has no settled prefix. + if (lastSpeech < 0) return new Set() + return new Set(items.slice(0, lastSpeech).map((i) => i.key)) +} + +// How many rows off the FRONT of the list are final — the count handed to +// . A prefix, deliberately: rows are flushed in screen order, so the +// first row that can still change stops the scan even if later rows are +// settled. Pure, for tests. +export function settledRowCount( + rows: readonly TranscriptRow[], + settledKeys: ReadonlySet, +): number { + let n = 0 + for (const row of rows) { + if (!settledKeys.has(row.entryKey)) break + n++ + } + return n +} + // The scroll position as (entry, row within that entry) rather than a flat row // index, so appends, re-wraps and expansions can't slide the window: the row // you parked on stays the row on screen. diff --git a/test/config.test.ts b/test/config.test.ts index 7e0fcdd..9a81c87 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -232,7 +232,6 @@ describe('sessionBar', () => { hosts: {}, sessionBar: { hidden: true, - rows: 8, days: 30, repo: 'any', statuses: 'unfinished', @@ -241,7 +240,6 @@ describe('sessionBar', () => { }) expect(sessionBar()).toEqual({ hidden: true, - rows: 8, days: 30, repo: 'any', statuses: 'unfinished', @@ -250,8 +248,8 @@ describe('sessionBar', () => { }) it('fills in the fields the file leaves out', () => { - writeConfig({ version: 2, hosts: {}, sessionBar: { rows: 3 } }) - expect(sessionBar()).toEqual({ ...SESSION_BAR_DEFAULTS, rows: 3 }) + writeConfig({ version: 2, hosts: {}, sessionBar: { days: 3 } }) + expect(sessionBar()).toEqual({ ...SESSION_BAR_DEFAULTS, days: 3 }) }) // A typo in a preference should not stop the UI from opening. @@ -259,7 +257,7 @@ describe('sessionBar', () => { writeConfig({ version: 2, hosts: {}, - sessionBar: { hidden: 'yes', rows: 0, days: -3, repo: 'origin', statuses: 'live' }, + sessionBar: { hidden: 'yes', days: -3, repo: 'origin', statuses: 'live' }, }) expect(sessionBar()).toEqual(SESSION_BAR_DEFAULTS) }) diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index d09e4ef..28f6b19 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -20,6 +20,8 @@ import { itemRows, layOutItems, rowViewport, + settledItemKeys, + settledRowCount, snapAnchorForEntry, snapToEntry, spanColor, @@ -733,6 +735,57 @@ describe('cursorLineDown', () => { }) }) +describe('settledItemKeys', () => { + const item = (key: string, kind: TranscriptItem['kind'], text = 'x'): TranscriptItem => ({ + key, + kind, + text, + }) + + it('settles everything when no turn is in flight', () => { + const items = [item('a', 'user'), item('b', 'assistant'), item('c', 'tool')] + expect([...settledItemKeys(items, false)]).toEqual(['a', 'b', 'c']) + }) + + it('holds back the last message and its tool run while a turn is live', () => { + // 'b' is the message the open run hangs off, so → can still unfold it and + // its fold row can still grow: neither may be flushed yet. + const items = [item('a', 'user'), item('b', 'assistant'), item('c', 'tool')] + expect([...settledItemKeys(items, true)]).toEqual(['a']) + }) + + it('flushes a finished message once the agent moves on to the next', () => { + const items = [ + item('a', 'user'), + item('b', 'assistant'), + item('c', 'tool'), + item('d', 'assistant'), + ] + expect([...settledItemKeys(items, true)]).toEqual(['a', 'b', 'c']) + }) + + it('settles nothing when the transcript is only an open tool run', () => { + expect(settledItemKeys([item('c', 'tool')], true).size).toBe(0) + }) +}) + +describe('settledRowCount', () => { + const row = (id: string, entryKey: string): TranscriptRow => ({ id, entryKey, spans: [] }) + + it('counts the settled PREFIX, stopping at the first live row', () => { + const rows = [row('1', 'a'), row('2', 'a'), row('3', 'b'), row('4', 'c')] + // 'c' is settled too, but it sits behind live 'b' — rows flush in screen + // order, so the scan stops there. + expect(settledRowCount(rows, new Set(['a', 'c']))).toBe(2) + }) + + it('is 0 when the first row is live and everything when all are settled', () => { + const rows = [row('1', 'a'), row('2', 'b')] + expect(settledRowCount(rows, new Set(['b']))).toBe(0) + expect(settledRowCount(rows, new Set(['a', 'b']))).toBe(2) + }) +}) + describe('rowViewport', () => { it('follows the bottom by default, filling the window', () => { expect(rowViewport(10, 4, null)).toMatchObject({ start: 7, end: 10, hiddenBelow: 0 }) diff --git a/test/connect-render.test.ts b/test/connect-render.test.ts new file mode 100644 index 0000000..ebdd75d --- /dev/null +++ b/test/connect-render.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest' +import React from 'react' +import { render } from 'ink' +import { PassThrough } from 'node:stream' +import stripAnsi from 'strip-ansi' +import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' +import { SESSION_STREAM_PROTOCOL_VERSION } from '@ellipsis-dev/sdk/stream' +import { ConnectApp } from '../src/ui/ConnectApp' + +// End-to-end render of the real chat against a fake TTY: the scrollback view is +// a claim about what reaches the TERMINAL (settled rows printed once, above a +// repainting live frame), and only an actual render can check it. The stream and +// API are stubbed — nothing here talks to a network. + +const h = React.createElement + +function fakeTty(): { stream: NodeJS.WriteStream; output: () => string } { + const stream = new PassThrough() as unknown as NodeJS.WriteStream + let out = '' + stream.on('data', (chunk: Buffer) => { + out += chunk.toString() + }) + const tty = stream as unknown as { isTTY: boolean; columns: number; rows: number } + tty.isTTY = true + tty.columns = 80 + tty.rows = 24 + return { stream, output: () => out } +} + +// A stdin the app's keyboard handlers will accept: without raw-mode support ink +// throws out of useInput and the app never gets to render its own frame. +function fakeStdin(): NodeJS.ReadStream { + const stdin = new PassThrough() as unknown as NodeJS.ReadStream + const tty = stdin as unknown as { + isTTY: boolean + setRawMode: () => unknown + ref: () => void + unref: () => void + } + tty.isTTY = true + tty.setRawMode = () => stdin + tty.ref = () => {} + tty.unref = () => {} + return stdin +} + +// How many times a string was written to the terminal. The unit of the whole +// scrollback claim: a FLUSHED row is written once and then belongs to the +// terminal, while a live row is rewritten by every repaint. Comparing the two +// counts in one render is what distinguishes them — and it is why each test +// forces a repaint, so "once" means "survived repaints", not "never repainted". +function writes(raw: string, needle: string): number { + return stripAnsi(raw).split(needle).length - 1 +} + +// A cost tick, which changes the footer's total and so forces a real repaint of +// the live region without touching the transcript. (An IDENTICAL frame is +// skipped by ink, so the value has to actually differ.) +function costTick(store: SessionTranscriptStore, cents: number): void { + store.ingest({ + type: 'session', + session: { + id: 'session_render', + status: 'waiting', + cost_tokens: cents, + cost_sandbox_cpu: 0, + cost_sandbox_memory: 0, + cost_fee: 0, + tokens_total: cents, + tokens_model: 'claude-fable-5', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) +} + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 40)) + +let seq = 0 +// A claude_code assistant-message record — the shape recordToItems turns into a +// ● prose row. +function say(text: string): Record { + return { + feed_seq: ++seq, + source: 'claude_code', + record_type: 'event', + payload: { + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text }] }, + }, + } +} + +function lifecycle(recordType: string, payload: Record = {}) { + return { feed_seq: ++seq, source: 'lifecycle', record_type: recordType, payload } +} + +function seededStore(records: Record[], status: string) { + const store = new SessionTranscriptStore() + const session = { + id: 'session_render', + status, + cost_tokens: 0, + cost_sandbox_cpu: 0, + cost_sandbox_memory: 0, + cost_fee: 0, + tokens_total: 0, + tokens_model: 'claude-fable-5', + } + store.ingest({ + type: 'snapshot', + protocol: SESSION_STREAM_PROTOCOL_VERSION, + earliest_feed_seq: null, + session, + messages: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store.ingest({ type: 'records_append', records } as any) + return store +} + +// The app with its network edges stubbed: the socket factory never connects, so +// the render is driven entirely by the seeded store. +function chat(store: SessionTranscriptStore, extra: Record = {}) { + return h(ConnectApp, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + api: {} as any, + sessionId: 'session_render', + store, + // Never resolves: no stream, no frames, no timers of its own. + openSocket: () => new Promise(() => {}), + canSend: true, + minRenderFeedSeq: 0, + sessionUrl: 'https://app.ellipsis.dev/acme?session=session_render', + model: 'claude-fable-5', + ...extra, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) +} + +describe('ConnectApp — the scrollback view', () => { + it('prints a settled transcript once, and keeps printing it once as the frame repaints', async () => { + // 'waiting' = no turn in flight, so every message is settled and flushes. + const store = seededStore( + [lifecycle('sandbox_ready', {}), say('first message'), say('second message')], + 'waiting', + ) + const { stream, output } = fakeTty() + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), patchConsole: false }) + await settle() + // Two more repaints of the live region, so "written once" below is a real + // claim about the flush rather than an artifact of a single frame. + // Big enough to move the dollar figure in the footer: $0.00 -> $0.50 -> $1.50. + costTick(store, 50_000) + await settle() + costTick(store, 150_000) + await settle() + app.unmount() + const raw = output() + // The live region demonstrably repainted... + expect(writes(raw, 'total')).toBeGreaterThan(1) + // ...and the settled rows were written once anyway: they are the terminal's + // now, which is what makes the wheel scroll them. + expect(writes(raw, 'first message')).toBe(1) + expect(writes(raw, 'second message')).toBe(1) + }) + + it('holds the last message in the live frame while a turn is in flight', async () => { + // A live turn means the newest message can still grow a tool run under it, so + // it must NOT be flushed — it stays in the repainting region, and the older + // message flushes without it. + const store = seededStore( + [ + lifecycle('sandbox_ready', {}), + say('older message'), + say('newest message'), + lifecycle('turn_started', { turn_id: 't1' }), + ], + 'working', + ) + 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() + // The older message settled and flushed: written once despite the repaint. + expect(writes(raw, 'older message')).toBe(1) + // The newest one is still live — a tool run can still land under it — so it + // is rewritten by each repaint instead. + expect(writes(raw, 'newest message')).toBeGreaterThan(1) + }) + + it('opens with a rule naming the session when it follows another chat', async () => { + const store = seededStore([lifecycle('sandbox_ready', {}), say('hello')], 'waiting') + const { stream, output } = fakeTty() + const app = render(chat(store, { scrollbackBreak: true }), { + stdout: stream, + stdin: fakeStdin(), + patchConsole: false, + }) + await settle() + app.unmount() + const text = stripAnsi(output()) + expect(text).toContain('session_render') + expect(text).toContain('─') + }) + + it('does not capture the mouse in the chat, so the terminal keeps the wheel', async () => { + // The point of the whole exercise: no SGR mouse reporting (1000h/1006h) is + // armed while the chat is the view, which is what leaves wheel scrolling and + // select/copy to the terminal. + const store = seededStore([lifecycle('sandbox_ready', {}), say('hello')], 'waiting') + const { stream, output } = fakeTty() + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), patchConsole: false }) + await settle() + app.unmount() + expect(output()).not.toContain('[?1000h') + expect(output()).not.toContain('[?1049h') + }) +}) diff --git a/test/scrollback.test.ts b/test/scrollback.test.ts new file mode 100644 index 0000000..1c4ce5e --- /dev/null +++ b/test/scrollback.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import React, { useEffect, useState } from 'react' +import { Box, Static, Text, render, useApp } from 'ink' +import { PassThrough } from 'node:stream' +import stripAnsi from 'strip-ansi' +import { useAltScreen } from '../src/ui/altScreen' + +// Offline render harness for the two terminal mechanisms the scrollback view +// rests on: ink's flush and the alternate-screen hop. Neither is +// observable in the React tree — the difference is in the BYTES written — so +// both are driven against a fake TTY stream and asserted on its output. +// createElement rather than JSX: the suite is .ts by convention. +const h = React.createElement + +// A stdout ink will treat as an interactive terminal, recording what is written. +function fakeTty(): { stream: NodeJS.WriteStream; output: () => string } { + const stream = new PassThrough() as unknown as NodeJS.WriteStream + let out = '' + stream.on('data', (chunk: Buffer) => { + out += chunk.toString() + }) + const tty = stream as unknown as { isTTY: boolean; columns: number; rows: number } + tty.isTTY = true + tty.columns = 80 + tty.rows = 24 + return { stream, output: () => out } +} + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 25)) + +describe(' flush — the scrollback view', () => { + it('prints each settled row ONCE and never reprints it', async () => { + // The invariant the whole scrollback view rests on: a flushed row is printed + // and then belongs to the terminal. Reprinting shows up as a duplicated + // transcript, which is what makes this worth pinning down. + const { stream, output } = fakeTty() + function App(): React.ReactElement { + const [rows, setRows] = useState(['alpha']) + useEffect(() => { + // A second row settles, exactly as a second message would. + const t = setTimeout(() => setRows(['alpha', 'bravo']), 5) + return () => clearTimeout(t) + }, []) + return h( + Box, + { flexDirection: 'column' }, + h(Static, { items: rows }, (row: string) => h(Text, { key: row }, row)), + h(Text, null, 'live'), + ) + } + const app = render(h(App), { stdout: stream, patchConsole: false }) + await settle() + app.unmount() + const text = stripAnsi(output()) + expect(text.match(/alpha/g)?.length).toBe(1) + expect(text.match(/bravo/g)?.length).toBe(1) + }) + + it('reprints from the start when the item list SHRINKS', async () => { + // Why ConnectApp holds flushed rows in an append-only ref instead of + // re-deriving them each frame: re-syncs its printed count from + // items.length, so a shorter list (what withholding the flush for the alt + // screen would produce) makes the next full list reprint what was already on + // screen. + const { stream, output } = fakeTty() + function App(): React.ReactElement { + const [rows, setRows] = useState(['alpha']) + useEffect(() => { + const shrink = setTimeout(() => setRows([]), 5) + const grow = setTimeout(() => setRows(['alpha']), 15) + return () => { + clearTimeout(shrink) + clearTimeout(grow) + } + }, []) + return h(Static, { items: rows }, (row: string) => h(Text, { key: row }, row)) + } + const app = render(h(App), { stdout: stream, patchConsole: false }) + await settle() + app.unmount() + expect(stripAnsi(output()).match(/alpha/g)?.length).toBe(2) + }) +}) + +describe('useAltScreen', () => { + it('enters the alt buffer on open and restores the primary one on close', async () => { + const { stream, output } = fakeTty() + function App({ open }: { open: boolean }): React.ReactElement { + useAltScreen(open) + return h(Text, null, 'frame') + } + const app = render(h(App, { open: false }), { stdout: stream, patchConsole: false }) + await settle() + expect(output()).not.toContain('[?1049h') + + app.rerender(h(App, { open: true })) + await settle() + expect(output()).toContain('[?1049h') + + app.rerender(h(App, { open: false })) + await settle() + expect(output()).toContain('[?1049l') + app.unmount() + }) + + it('writes nothing to a non-TTY stdout — the headless --no-input follow', async () => { + const stream = new PassThrough() as unknown as NodeJS.WriteStream + let out = '' + stream.on('data', (chunk: Buffer) => { + out += chunk.toString() + }) + function App(): React.ReactElement { + useAltScreen(true) + return h(Text, null, 'frame') + } + const app = render(h(App), { stdout: stream, patchConsole: false, interactive: true }) + await settle() + app.unmount() + expect(out).not.toContain('[?1049') + }) + + it('restores the primary buffer when the app exits while open', async () => { + // Quitting from inside the browser must not strand the shell on the alt + // screen. + const { stream, output } = fakeTty() + function App(): React.ReactElement { + useAltScreen(true) + const { exit } = useApp() + useEffect(() => { + const t = setTimeout(() => exit(), 5) + return () => clearTimeout(t) + }, [exit]) + return h(Text, null, 'frame') + } + const app = render(h(App), { stdout: stream, patchConsole: false }) + await settle() + app.unmount() + await settle() + expect(output()).toContain('[?1049l') + }) +}) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 6f7a09f..e47d788 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -199,7 +199,6 @@ describe('sessionSource', () => { describe('sessionBarQuery', () => { const bar = { - rows: 5, days: 7, repo: 'cwd' as const, statuses: 'all' as const, @@ -250,8 +249,8 @@ describe('sessionBarQuery', () => { ).toBeUndefined() }) - it('fetches at least as many rows as the bar displays', () => { - expect(sessionBarQuery({ ...bar, rows: 200 }, context).limit).toBe(200) + it('fetches a page deep enough to band and scroll', () => { + expect(sessionBarQuery(bar, context).limit).toBe(SESSION_BAR_FETCH) }) }) From 3038ecc2cdbf34ad7a4517976b98a279258779e6 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 19 Aug 2026 17:09:50 -0400 Subject: [PATCH 2/5] ui: stop painting backgrounds, which scrollback cannot keep in step 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. --- src/lib/theme.ts | 107 ++++++++---------------------------- src/ui/ConnectApp.tsx | 92 ++++++++++++------------------- src/ui/SessionsApp.tsx | 88 +++++++++++------------------ src/ui/transcriptRows.ts | 80 ++++++--------------------- test/connect-app.test.ts | 20 ++++--- test/connect-render.test.ts | 22 ++++++++ test/theme.test.ts | 59 ++++---------------- 7 files changed, 151 insertions(+), 317 deletions(-) diff --git a/src/lib/theme.ts b/src/lib/theme.ts index eefbcf9..e295205 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,5 +1,3 @@ -import chalk from 'chalk' - // The Ellipsis brand palette, dark mode — the CLI's one source of color. // // These hexes are COPIES of brand/tokens.json in the ellipsis monorepo (the @@ -10,91 +8,42 @@ import chalk from 'chalk' // only ever renders the dark palette — there is no light variant to switch to. // // One rule carried over from the web app (landing globals.css `.dark`): the -// accent in dark mode is BONE, not brand blue. Brand ink #175173 scores -// 1.79:1 on the panel — unreadable as terminal text. So emphasis is carried by +// accent in dark mode is BONE, not brand blue. Brand ink #175173 scores 1.79:1 +// on a dark surface — unreadable as terminal text. So emphasis is carried by // brightness (bone against stone), not by hue. The ▶ cursor is the one // exception, and takes `cursor` below. // -// Because the CLI paints its own canvas, the palette only holds if it is used -// for EVERY cell of the frame. Two rules keep it whole on a terminal whose own -// theme is light: -// -// 1. Every glyph takes a color from this file. Ink leaves a `` with no -// `color` prop on the terminal's DEFAULT foreground, which under a light -// theme is near-black — the same near-black we just painted the canvas -// with, so the text vanishes. `dimColor` on its own is that bug plus an -// \x1b[2m: secondary copy takes `muted`, never a bare `dimColor`. (dim is -// fine ON TOP of an explicit color, where it only shades a known hue.) -// 2. Surfaces reach ink already quantized for the terminal's color depth — -// see `surfaceFor`, which is why the three surface entries below are -// computed rather than literal. - -// The surfaces as authored. Call sites never read these: they take the -// `theme.*` entries, which are these run through `surfaceFor`. -const BRAND_SURFACES = { - canvas: '#1c1b1a', - panel: '#262523', - panelActive: '#343330', -} as const - -// A surface hex ink can paint at `level` without losing the step between one -// surface and the next. -// -// chalk resolves a hex onto the 256-color palette two different ways: to the -// 24-rung GREYSCALE RAMP (indexes 232-255, ~10 units apart) when r, g and b -// are equal, and otherwise to the 6x6x6 COLOR CUBE, whose darkest step above -// black is rgb(95,95,95). The brand surfaces are WARM greys — their channels -// differ by a point or two — so on a terminal that does 256 colors but not -// truecolor (Terminal.app, tmux without RGB, mosh, plain conhost) all three -// land on cube index 59 simultaneously: the near-black canvas paints as a mid -// grey slab, and the panel and active steps disappear along with every "you -// are here" highlight that was carried by them. +// THE CLI PAINTS NO BACKGROUNDS. It used to paint a canvas and lift inputs onto +// tinted panels, which worked while the app owned every cell of a fixed frame. +// It no longer does: the transcript is printed into the terminal's own +// scrollback, where a row is never repainted, so any fill it carries outlives +// the frame that justified it — stale bands survive a resize or a shorter frame +// and cannot be cleaned up. Structure is carried by glyphs and foreground colour +// instead, and the terminal's own background shows through everywhere. // -// Averaging the channels is invisible at this brightness (a warm near-black -// and a neutral near-black are the same wall of dark) and puts each surface -// back on its own rung: 234, 235, 236. Truecolor terminals get the authored -// warmth untouched; a 16-color terminal renders both spellings as its palette -// black, so the substitution costs nothing there either. -export function surfaceFor(hex: string, level: number): string { - if (level >= 3) return hex - const value = hex.replace('#', '') - const channels = [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16)) - if (channels.some(Number.isNaN)) return hex - const mean = Math.round((channels[0] + channels[1] + channels[2]) / 3) - return `#${mean.toString(16).padStart(2, '0').repeat(3)}` -} - -// `chalk.level` is read once, at import: ink colorizes through this very chalk -// instance (it is a hoisted single copy), so what it can render is what we -// quantize for. -const COLOR_LEVEL: number = chalk.level +// One rule follows from that, and it still matters: every glyph takes a colour +// from this file. Ink leaves a `` with no `color` prop on the terminal's +// DEFAULT foreground, so a hardcoded assumption either way breaks one theme; +// `dimColor` on its own is that bug plus an \x1b[2m, so secondary copy takes +// `muted`, never a bare `dimColor`. (dim is fine ON TOP of an explicit colour, +// where it only shades a known hue.) export const theme = { - // The app canvas and the lifted panel an input sits on. ~1.1:1 apart: barely - // a lift, which is the point — a panel should separate, not stripe. - canvas: surfaceFor(BRAND_SURFACES.canvas, COLOR_LEVEL), - panel: surfaceFor(BRAND_SURFACES.panel, COLOR_LEVEL), - // One step lighter than `panel`: the brand border hairline, doing duty as - // the "you are here" surface (highlighted message, focused composer, - // selected nav row). Selection is a brightness step between surfaces — - // never the full inverse flash, which reads bone-white and far too loud. - panelActive: surfaceFor(BRAND_SURFACES.panelActive, COLOR_LEVEL), - // Type. `foreground` is body copy and doubles as the accent (see above); // `muted` is every secondary string (meta, hints, timestamps) — and, since - // rule 1 above rules out a bare `dimColor`, it is also how a quiet line - // reads quiet. 7.4:1 on the canvas, so quiet still means legible. + // the rule above rules out a bare `dimColor`, it is also how a quiet line + // reads quiet. 7.4:1 on the brand charcoal, so quiet still means legible. foreground: '#f0efe9', muted: '#a8a59c', - // The ▶ cursor, and nothing else. Bone-on-stone was too quiet a step to find - // at a glance on a busy frame, so the cursor carries HUE as well as - // brightness: cyan is the one hue not already spoken for (green = done, - // amber = working, red = failed), so it never reads as a status. 9.7:1 on - // the canvas and 7.2:1 on the active surface, so it holds up highlighted. + // The ▶ cursor, and nothing else — which, with no highlight bar to fall back + // on, is now the ONLY thing that says "you are here". Bone-on-stone was too + // quiet a step to find at a glance, so the cursor carries HUE as well as + // brightness: cyan is the one hue not already spoken for (green = done, amber + // = working, red = failed), so it never reads as a status. cursor: '#5fd3e0', - // Status. Tuned for the charcoal canvas, not the light one. + // Status. success: '#4ebc7b', error: '#e5544b', // In-flight. brand/tokens.json has no dedicated "working" color; this is @@ -107,13 +56,3 @@ export const theme = { syntaxLiteral: '#d9bd8d', syntaxString: '#c8c6bc', } - -// The elevated surface an input area sits on. Named separately from -// `theme.panel` because call sites mean "this is an input", not "this is -// #262523" — the composers in ConnectApp/SessionsApp both use it. -export const SURFACE_ELEVATED = theme.panel - -// The elevated surface, active: the focused composer, the highlighted -// transcript message, the selected nav row. One brightness step above -// SURFACE_ELEVATED — enough to read "you are here" without the inverse flash. -export const SURFACE_ACTIVE = theme.panelActive diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index e2ef62f..56bf95f 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -36,7 +36,7 @@ import { applyEditShortcut } from '../lib/editing' import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' import { fitLines, visibleWidth } from '../lib/markdown' import { SELECTION_GLYPH } from '../lib/sessions' -import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' +import { theme } from '../lib/theme' import { useAltScreen } from './altScreen' import { VERSION } from '../lib/constants' import { @@ -54,7 +54,6 @@ import { LIVE_GLYPH, MESSAGE_PAD, navKeyOf, - padPanelBlocks, pendingMessageRows, rowViewport, settledItemKeys, @@ -800,10 +799,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ? Math.max(0, Math.min(fitLines(`· ${shownNotice}`, cols).length, free - 2)) : 0 free -= noticeRows - // The composer panel: its interior grows with the input, plus the 1-cell - // pad above and below. No rules to account for — the tint is the frame. In - // a pane too short for all of it the pad goes, then the interior shrinks - // toward a single row. + // The composer: its interior grows with the input, plus the 1-cell pad above + // and below. In a terminal too short for all of it the pad goes, then the + // interior shrinks toward a single row. const typedRows = fitLines(composer.text, composerTextCols(cols)).length const wanted = Math.max(COMPOSER_INTERIOR_ROWS, typedRows) + 2 const composerRows = composerVisible ? Math.max(1, Math.min(wanted, free - 1)) : 0 @@ -956,7 +954,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // full-colour ◆ rows ABOVE the live activity — the running turn is the // response to THIS message, so its stream belongs below it. for (const q of inFlightSends.filter((q) => q.state === 'accepted')) { - out.push(...pendingMessageRows(q.key, q.text, cols, { gutter: '◆', bold: true, panel: true })) + out.push(...pendingMessageRows(q.key, q.text, cols, { gutter: '◆', bold: true })) } if (liveTail.text) { out.push(...pendingMessageRows('live', liveTail.text, cols, { gutter: '' })) @@ -984,7 +982,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { dim: true, right: 'queued', pulse: true, - panel: true, }), ) } @@ -999,13 +996,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { dim: true, right: q.state === 'sending' ? 'sending' : q.state === 'queued' ? 'queued' : 'cancelled', pulse: waiting, - panel: true, }), ) } - // Every lifted block gets its blank tinted row above and below, here so a - // message and the tool run attached under it share one pad. - return padPanelBlocks(out) + return out }, [ infraActivity, sandbox, @@ -1436,11 +1430,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // Scrollback mode does NOT fill the terminal: the frame is only as tall // as the live tail plus the footer, and it grows downward from wherever // the last flushed row left the cursor. A minHeight here would hold the - // composer at the bottom of the screen with a screenful of blank canvas - // above it, and every flushed row would push that blank block down. The + // composer at the bottom of the screen with a screenful of blank rows + // above it, and every flushed row would push that block down. The // browser is the opposite: it fills the alt screen it took over. minHeight={windowed ? Math.max(0, rows - bottomSlack) : undefined} - backgroundColor={theme.canvas} > {/* The settled transcript, printed once into the terminal's own scrollback and never repainted — which is what makes the wheel, the @@ -1458,13 +1451,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} )} - {/* Top padding — see the termRows comment: absorbs terminal row- - accounting quirks and the post-exit sign-off so the first content - line never scrolls out of the window. */} {padRows > 0 && } - {/* The one-line opener is the whole banner — the rest of the session - identity (dashboard link, model, version) lives in the footer meta - line, so nothing is printed to scrollback before the app. */} {/* The chat window: ONE flat list of rows, sliced to exactly the rows that fit. Everything lives in it — the startup block, the transcript, in-flight sends, the live activity lines — so nothing @@ -1476,9 +1463,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { rowViewport already emits exactly the rows that fit. */} · {shownNotice} )} - {/* The composer: the input area on the elevated surface — one step - lighter (the active surface) while it's where you are (focused, no - transcript highlight), matching the transcript's selection - treatment. The tint is the whole frame — no rules — so the panel - reads as lifted off the canvas rather than fenced in by lines. A - uniform 1-cell pad keeps the text off all four edges, and because - it's inside the tinted Box the gutter carries the panel color too. - The panel is pinned to the rows budgeted for it, and clips: a pane - too short for the whole input scrolls it (below) rather than - painting the overflow across the meta line. */} + {/* The composer. Unpainted, like everything else: what marks it as the + input is the ▶ prompt and the caret, not a tint. It is pinned to the + rows budgeted for it and clips, so a terminal too short for the whole + input scrolls it rather than painting the overflow across the meta + line. */} {composerVisible && ( = COMPOSER_INTERIOR_ROWS + 2 ? 1 : 0} paddingX={COMPOSER_PAD_X} > @@ -1562,11 +1542,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {/* The prompt is the selection glyph while the composer is where you are (focused, no transcript highlight) — the same cyan marker as everywhere else — and dim when it isn't. */} - {/* The explicit colour on the parent is what the bare text - children below inherit — ink would otherwise leave the typed - text on the terminal's default foreground, unreadable against - the panel on a light theme — and it gives the inverse caret a - known pair of colours to swap. */} + {/* The explicit colour on the parent is what the bare text children + below inherit, and it gives the inverse caret a known pair of + colours to swap. */} + {row.indent ? : null} diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index f3a79c2..b1d94f9 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -43,7 +43,7 @@ import { } from '../lib/sessions' import type { ResolvedSessionBar } from '../lib/config' import { randomFact } from '../lib/facts' -import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' +import { theme } from '../lib/theme' import { useAltScreen } from './altScreen' import { ConnectApp } from './ConnectApp' @@ -62,7 +62,7 @@ import { ConnectApp } from './ConnectApp' // alternate-screen view: the windowed transcript with folding and ↑/↓ nav, // which the scrollback view cannot do. // * the new-session composer and the loading placeholder do own their frame, -// so they keep the header band and the canvas inset. +// so they keep the header band and the frame inset. // // Focus is modal and esc steps outward: inside the chat esc closes the browser, // then transcript navigation, then opens the picker. Exactly one useInput @@ -82,8 +82,8 @@ const NAV_NEW_LABEL = '+ New session' const HEADER_TITLE = 'ellipsis.dev' const TITLE_WIDTH = HEADER_TITLE.length -// Blank canvas cells between the frame and the terminal edge, on all four -// sides. Everything inside lays out against the inset width/height. +// Blank cells between the frame and the terminal edge, on all four sides. +// Everything inside lays out against the inset width/height. const APP_INSET = 1 // Extra indent for the session-nav rows, on top of the app inset — the list @@ -159,9 +159,9 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { } }, [stdout]) const height = Math.max(8, termRows - 1) - // The app inset: one blank cell of canvas on all four sides of the whole - // frame, so nothing sits flush against the terminal edge. The bands lay out - // inside it, so every width/height below is the INNER box, not the terminal. + // The app inset: one blank cell on all four sides of the whole frame, so + // nothing sits flush against the terminal edge. Everything lays out inside it, + // so every width/height below is the INNER box, not the terminal. const contentCols = Math.max(20, termCols - APP_INSET * 2) const contentRows = Math.max(6, height - APP_INSET * 2) @@ -495,16 +495,13 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { ? `@${props.ghLogin} in ${customerLogin}` : customerLogin const header = ( - // The top bar is a lifted surface, like the composer: the panel tint (not - // a rule) is what separates it from the transcript below. Its own 2-cell - // pad sits inside the tint, so the bar reads as a band with the title - // floating in it rather than type pinned to an edge. + // Unpainted, like every other surface: its own blank rows above and below + // are what set the title apart, not a tint. - {/* The highlighted row — this one and the session rows below — takes - the active surface across its full width (the same lighter panel - the focused composer sits on), never the inverse bar. */} - + {/* The highlighted row — this one and the session rows below — is marked + by the cyan ▶ in its gutter and nothing else. No bar: a fill would be + one more surface to keep in step with the terminal's own. */} + {selected === 'new' && navFocused ? ( @@ -663,10 +660,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { const meta = `${rowMeta(s)}${attention.has(s.id) ? ' · needs you' : ''}` const descW = Math.max(8, contentCols - NAV_GUTTER * 2 - meta.length - 6) return ( - // The highlighted row's background spans the whole width — the - // meta tag included — on the same active surface as everywhere - // else, replacing the old inverse (bone-white) description bar. - + @@ -718,38 +712,24 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // buffer, waiting behind it. if (navOpen) { return ( - + {header} {nav} ) } - // A LIVE CHAT owns the terminal outright: no canvas box around it, no header, - // no inset. All three would be frame furniture painted around a region that - // prints into the terminal's scrollback — the rows scrolling past would run - // straight through them. ConnectApp paints its own canvas per row instead. + // A LIVE CHAT owns the terminal outright: no box around it, no header, no + // inset. All three would be frame furniture around a region that prints into + // the terminal's scrollback, and the rows scrolling past would run straight + // through them. const chatOwnsScreen = mainPane.type === 'chat' && entries.has(mainPane.sessionId) if (chatOwnsScreen) return main // The remaining screens (the new-session composer, a chat still loading) do - // own their frame, so they keep the canvas, the inset and the header. + // own their frame, so they keep the inset and the header. return ( - // The brand canvas, painted edge to edge. Painting the root (rather than - // letting the terminal's own background show through) is what makes the - // charcoal→panel step read as intentional depth on ANY terminal theme - // instead of only on a dark one. - + {header} {main} @@ -842,10 +822,9 @@ const PICKER_ROWS: readonly PickerRow[] = [ // The new-session pane, mirroring the dashboard's home composer // (app.ellipsis.dev/[login]): a centered "What are we shipping today?" // heading floating in the empty space, and the input panel docked at the -// bottom — the Repository / Agent / Model rows inside the tinted box with -// the ❯ prompt line beneath them (the dashboard card's controls-inside-the- -// composer shape). The ❯ glyph marks whichever row is selected; the whole -// panel steps to the lighter active surface while any row has focus. ↑ from +// bottom — the Repository / Agent / Model rows above the ❯ prompt line (the +// dashboard card's controls-inside-the-composer shape). The ❯ glyph marks +// whichever row is selected. ↑ from // the prompt climbs into the option rows, ↑/↓ walk them (↓ off the last // returns to the prompt), →/enter unfolds a row's option list in place, // inside the panel ([x] marks the pick). Inside an open list ↑/↓ walk, → (or @@ -1160,21 +1139,16 @@ function NewSessionPane({ {error && ✗ {error}} {starting && ✻ Starting session…} - {/* The input panel — the SAME surface as the chat composer, stepping - onto the lighter active surface while ANY of its four rows is where - you are (a picker row, its open option list, or the prompt), no - rules, with a uniform 1-cell pad inside the tint. The run controls - live INSIDE it, one row each ABOVE the prompt (the dashboard card's - controls-inside-the-composer shape): Repository, Agent, Model, a - blank spacer row, then the prompt line. The ❯ selection glyph marks - whichever row is selected. →/enter opens a picker row IN PLACE — - its value collapses to a bare label and the option list unfolds - indented beneath it, inside the panel (the panel grows upward into - the spacer above; the prompt never moves). Repositories + {/* The input, laid out like the chat composer: the run controls one row + each ABOVE the prompt (the dashboard card's controls-inside-the- + composer shape) — Repository, Agent, Model, a blank row, then the + prompt line. The ❯ selection glyph marks whichever row is selected. + →/enter opens a picker row IN PLACE: its value collapses to a bare + label and the option list unfolds indented beneath it, growing upward + into the space above so the prompt never moves. Repositories multi-select ([x] toggles), the others pick one. */} ` -// on the terminal's default foreground, which under a LIGHT theme is the -// same near-black as the canvas we paint beneath it. Most spans carry no -// colour of their own (the assistant's prose included, via styleFor), so -// the bulk of a transcript came out dark on dark. See theme.ts, rule 1. +// * NO COLOUR MEANS THE TERMINAL'S COLOUR, which is a colour we do not know: +// most spans carry none of their own (the assistant's prose included, via +// styleFor), so leaving them bare hands the bulk of the transcript to +// whatever the user's theme happens to be. See theme.ts. // * DIM IS OPTIONAL, as far as terminals are concerned: \x1b[2m is dropped // outright by a fair number of them once a 24-bit foreground is also set. // That is the same reason a pulsing mark SWAPS its colour on the off beat @@ -101,11 +97,7 @@ export type TranscriptRow = { // Right-aligned metadata (a ticking duration, a pipeline state). The row's // spans are fitted to the columns left over. right?: RowSpan - // Sits on a message panel: the elevated tint. Only a message YOU sent does. - panel?: boolean - // A blank separator row. Off-panel it is bare canvas, so the gap between - // blocks reads as a gap. On a panel (panel + spacer) it is the block's - // vertical pad, and carries the tint. + // A blank separator row: the gap between blocks. spacer?: boolean // The "+N lines" marker under a clamped body. The key that opens it depends // on whether the line is highlighted (→) or not (ctrl+r), which the renderer @@ -165,35 +157,6 @@ export function navKeyOf(row: TranscriptRow): string { return row.navKey ?? row.entryKey } -// One blank tinted row above and below every maximal run of consecutive panel -// rows — the vertical pad around a message YOU sent, matching the composer's -// interior pad. Applied to the ASSEMBLED list rather than inside itemRows so a -// run of consecutive sends reads as one padded block instead of each bringing -// its own pad. Pure, for tests. -export function padPanelBlocks(rows: readonly TranscriptRow[]): TranscriptRow[] { - const out: TranscriptRow[] = [] - // A pad row inherits the edge row's BLOCK, not just its entry: a pad added - // below a message's nested tool line belongs to that message, and leaving - // navKey off would make the tool line a ↑/↓ stop of its own again. - const pad = (edge: TranscriptRow, side: string): TranscriptRow => ({ - id: `${edge.id}:${side}`, - entryKey: edge.entryKey, - navKey: edge.navKey, - spans: [], - panel: true, - spacer: true, - }) - for (const row of rows) { - const prev = out[out.length - 1] - if (row.panel && !prev?.panel) out.push(pad(row, 'padT')) - if (!row.panel && prev?.panel) out.push(pad(prev, 'padB')) - out.push(row) - } - const last = out[out.length - 1] - if (last?.panel) out.push(pad(last, 'padB')) - return out -} - // One transcript item as its screen rows: the separator above it, its body // pre-wrapped to the column it occupies, and the "+N lines" marker when a long // body is clamped. @@ -202,11 +165,6 @@ export function itemRows( cols: number, opts: { indent?: number; clamp: boolean; nested?: boolean; attach?: boolean }, ): TranscriptRow[] { - // Only what YOU said sits on the lifted, padded panel the composer uses. - // Everything the agent says or does — prose, tool chatter, notices — stays - // bare on the canvas, so the transcript reads as dense output with your turns - // marked out of it. - const panel = item.kind === 'user' const indent = opts.indent ?? 0 // Nested lines are marked by their INDENT, so each keeps the glyph that says // what it is: ● the call, ⎿ the result that came back. Only a collapsed fold @@ -240,7 +198,6 @@ export function itemRows( indent, textPad, spans, - panel, ...extra, }) } @@ -276,8 +233,8 @@ export function itemRows( // came last, YOURS INCLUDED — the agent often opens a turn with a tool call, // and a run that belonged to nothing could not be reached with →. But it only // INDENTS under something the AGENT said (isAgentSpeech: prose or ✻ thinking): -// your message is a lifted box, and a ⎿ branch under it would read as work YOU -// did, so a turn-opening run stays flat and separated by its own blank row. +// a ⎿ branch under your own message would read as work YOU did, so a +// turn-opening run stays flat and separated by its own blank row. // Only a run at the very top of the transcript, with no message above it at // all, belongs to nothing. // @@ -368,15 +325,14 @@ export function isToolActivity(item: TranscriptItem): boolean { // Whether a message is one the AGENT said, which is what a tool run may branch // off with its ⎿. Its prose and its ✻ thinking both count — with extended // thinking on, thinking is what most runs actually follow. Your own message -// does not: it is a lifted box, and a branch under it reads as work YOU did. +// does not: a branch under your own message reads as work YOU did. export function isAgentSpeech(item: TranscriptItem): boolean { return item.kind === 'assistant' || item.kind === 'thinking' } // A live status line — "Generating…", "Running Bash(pytest…)…" — with its -// ticking readout in the right-hand metadata column. It is the agent working, so -// it stays bare on the canvas. `hug` drops the spacer above so the line reads as -// part of the tool burst it belongs to. The duration is a `tick` marker rather +// ticking readout in the right-hand metadata column. `hug` drops the spacer +// above so the line reads as part of the tool burst it belongs to. The duration is a `tick` marker rather // than text: it changes every second, and baking it in here would re-wrap the // transcript once a second. export function activityRows( @@ -410,11 +366,10 @@ export function activityRows( } // An in-flight send, or the streaming assistant response: laid out exactly like -// the committed record it becomes, so nothing shifts when that record lands — -// which is why `panel` is the caller's call (your send is panelled, the -// streaming response is not). `pulse` marks the send as still in flight — the -// same breathing ⏺ a running tool wears, so a message the agent hasn't answered -// yet never reads as settled conversation. +// the committed record it becomes, so nothing shifts when that record lands. +// `pulse` marks the send as still in flight — the same breathing ⏺ a running +// tool wears, so a message the agent hasn't answered yet never reads as settled +// conversation. export function pendingMessageRows( key: string, text: string, @@ -425,10 +380,8 @@ export function pendingMessageRows( bold?: boolean right?: string pulse?: boolean - panel?: boolean }, ): TranscriptRow[] { - const panel = opts.panel ?? false const width = contentWidth(cols) const rows: TranscriptRow[] = [spacerRow(key, `${key}:sp`)] const lines = fitLines(text, width) @@ -442,7 +395,6 @@ export function pendingMessageRows( : undefined, spans: [{ text: line, dim: opts.dim, bold: opts.bold }], right: i === lines.length - 1 && opts.right ? { text: opts.right, dim: true } : undefined, - panel, pulse: i === 0 ? opts.pulse : undefined, }) } diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 28f6b19..c6e6845 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -865,15 +865,19 @@ describe('itemRows', () => { expect(rows).toHaveLength(1) }) - it('panels what you said and leaves everything the agent said on the canvas', () => { - const panelOf = (kind: TranscriptItem['kind'], nested = false): boolean | undefined => + // Every row is unpainted: a row prints into the terminal's scrollback and is + // never repainted, so a fill on it would outlive the frame that drew it. The + // sender is carried by the gutter glyph alone. + it('marks the sender with a gutter glyph and paints no background', () => { + const gutterOf = (kind: TranscriptItem['kind'], nested = false): string | undefined => itemRows({ key: 'a', kind, text: 'x' } as TranscriptItem, 40, { clamp: false, nested })[0] - .panel - expect(panelOf('user')).toBe(true) - expect(panelOf('assistant')).toBe(false) - expect(panelOf('notice')).toBe(false) - expect(panelOf('tool', true)).toBe(false) - expect(panelOf('tool_result', true)).toBe(false) + .gutter?.text + expect(gutterOf('user')).toBe('◆') + expect(gutterOf('assistant')).toBe('●') + expect(gutterOf('notice')).toBe('✦') + for (const row of itemRows({ key: 'a', kind: 'user', text: 'x' }, 40, { clamp: false })) { + expect(row).not.toHaveProperty('panel') + } }) it('emits one row per line of a multi-line body', () => { diff --git a/test/connect-render.test.ts b/test/connect-render.test.ts index ebdd75d..af1b992 100644 --- a/test/connect-render.test.ts +++ b/test/connect-render.test.ts @@ -207,6 +207,28 @@ describe('ConnectApp — the scrollback view', () => { expect(text).toContain('─') }) + it('paints no background, so the terminal\'s own shows through', async () => { + // A row printed into scrollback is never repainted, so a fill on it outlives + // the frame that drew it: stale bands survive a resize or a shorter frame + // with nothing able to clean them up. The app therefore emits no background + // SGR at all (48;2;r;g;b truecolor, 48;5;n indexed, or 40-47/100-107). + const store = seededStore( + [lifecycle('sandbox_ready', {}), say('hello'), say('and again')], + 'waiting', + ) + 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;') + }) + it('does not capture the mouse in the chat, so the terminal keeps the wheel', async () => { // The point of the whole exercise: no SGR mouse reporting (1000h/1006h) is // armed while the chat is the view, which is what leaves wheel scrolling and diff --git a/test/theme.test.ts b/test/theme.test.ts index f0fa567..f621011 100644 --- a/test/theme.test.ts +++ b/test/theme.test.ts @@ -1,52 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Chalk } from 'chalk' -import { surfaceFor, theme } from '../src/lib/theme' - -// The 256-color index chalk resolves a hex to, which is what ink paints with -// on a terminal that does 256 colors but not truecolor. -const ansi256 = (hex: string): number => { - const match = new Chalk({ level: 2 }).bgHex(hex)('x').match(/48;5;(\d+)/) - return Number(match?.[1]) -} - -describe('surfaceFor', () => { - it('leaves the authored warmth alone on a truecolor terminal', () => { - expect(surfaceFor('#1c1b1a', 3)).toBe('#1c1b1a') - expect(surfaceFor('#262523', 3)).toBe('#262523') - }) - - it('neutralizes the channels below truecolor', () => { - expect(surfaceFor('#1c1b1a', 2)).toBe('#1b1b1b') - expect(surfaceFor('#262523', 2)).toBe('#252525') - expect(surfaceFor('#343330', 2)).toBe('#323232') - expect(surfaceFor('#1c1b1a', 1)).toBe('#1b1b1b') - }) - - it('passes a malformed hex through rather than painting garbage', () => { - expect(surfaceFor('nonsense', 2)).toBe('nonsense') - }) - - // The bug this exists for: chalk sends any hex whose channels differ to the - // 6x6x6 color cube, whose darkest step above black is rgb(95,95,95). All - // three warm brand surfaces landed on that ONE index, so the near-black - // canvas painted mid grey and every surface step (and with it every "you are - // here" highlight) disappeared on a 256-color terminal. - it('keeps the three surfaces three distinct steps on a 256-color terminal', () => { - const authored = [ansi256('#1c1b1a'), ansi256('#262523'), ansi256('#343330')] - expect(new Set(authored).size).toBe(1) - expect(authored[0]).toBe(59) - - const painted = [ - ansi256(surfaceFor('#1c1b1a', 2)), - ansi256(surfaceFor('#262523', 2)), - ansi256(surfaceFor('#343330', 2)), - ] - expect(new Set(painted).size).toBe(3) - // On the greyscale ramp (232-255), where a near-black stays near-black. - expect(painted.every((index) => index >= 232)).toBe(true) - expect(painted).toEqual([...painted].sort((a, b) => a - b)) - }) -}) +import { theme } from '../src/lib/theme' describe('theme', () => { it('carries a color for every token, so no call site has to fall back', () => { @@ -54,4 +7,14 @@ describe('theme', () => { expect(value, name).toMatch(/^#[0-9a-f]{6}$/) } }) + + // The palette paints FOREGROUNDS only. Backgrounds are the terminal's, because + // transcript rows print into its scrollback and are never repainted — a fill + // there outlives the frame that drew it. No surface tokens means no call site + // can reintroduce one by reaching for a plausible name. + it('holds no surface colors', () => { + for (const name of Object.keys(theme)) { + expect(name).not.toMatch(/canvas|panel|surface|background/i) + } + }) }) From 041f426966dcef1f1b2b82587029b12c2b051437 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 19 Aug 2026 17:18:47 -0400 Subject: [PATCH 3/5] ui: add slash commands with autocomplete, and align the opening line 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. --- src/lib/theme.ts | 43 +++++++--- src/ui/ConnectApp.tsx | 159 ++++++++++++++++++++++++++++++------ src/ui/SessionsApp.tsx | 3 +- src/ui/commands.ts | 72 ++++++++++++++++ test/commands.test.ts | 103 +++++++++++++++++++++++ test/connect-render.test.ts | 55 +++++++++++-- 6 files changed, 386 insertions(+), 49 deletions(-) create mode 100644 src/ui/commands.ts create mode 100644 test/commands.test.ts diff --git a/src/lib/theme.ts b/src/lib/theme.ts index e295205..3e358d1 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,3 +1,5 @@ +import chalk from 'chalk' + // The Ellipsis brand palette, dark mode — the CLI's one source of color. // // These hexes are COPIES of brand/tokens.json in the ellipsis monorepo (the @@ -13,20 +15,25 @@ // brightness (bone against stone), not by hue. The ▶ cursor is the one // exception, and takes `cursor` below. // -// THE CLI PAINTS NO BACKGROUNDS. It used to paint a canvas and lift inputs onto -// tinted panels, which worked while the app owned every cell of a fixed frame. -// It no longer does: the transcript is printed into the terminal's own -// scrollback, where a row is never repainted, so any fill it carries outlives -// the frame that justified it — stale bands survive a resize or a shorter frame -// and cannot be cleaned up. Structure is carried by glyphs and foreground colour -// instead, and the terminal's own background shows through everywhere. +// EXACTLY ONE SURFACE IS PAINTED: the composer's (`inputSurface` below). The CLI +// used to paint a canvas behind everything and lift panels onto it, which worked +// while the app owned every cell of a fixed frame. It no longer does — the +// transcript is printed into the terminal's own scrollback, where a row is never +// repainted, so a fill there outlives the frame that drew it and stale bands +// survive a resize or a shorter frame with nothing able to clean them up. +// +// The composer is the exception because it is the one region that is ALWAYS +// repainted and NEVER flushed: it lives in the live frame for the whole session, +// so its fill is redrawn on every frame and disappears with the app. Transcript +// rows, the header, and list highlights are all either printed or sized around +// printed rows, so they carry no fill. // -// One rule follows from that, and it still matters: every glyph takes a colour -// from this file. Ink leaves a `` with no `color` prop on the terminal's -// DEFAULT foreground, so a hardcoded assumption either way breaks one theme; -// `dimColor` on its own is that bug plus an \x1b[2m, so secondary copy takes -// `muted`, never a bare `dimColor`. (dim is fine ON TOP of an explicit colour, -// where it only shades a known hue.) +// One rule survives from the canvas days, and it still matters: every glyph takes +// a colour from this file. Ink leaves a `` with no `color` prop on the +// terminal's DEFAULT foreground, so a hardcoded assumption either way breaks one +// theme; `dimColor` on its own is that bug plus an \x1b[2m, so secondary copy +// takes `muted`, never a bare `dimColor`. (dim is fine ON TOP of an explicit +// colour, where it only shades a known hue.) export const theme = { // Type. `foreground` is body copy and doubles as the accent (see above); @@ -56,3 +63,13 @@ export const theme = { syntaxLiteral: '#d9bd8d', syntaxString: '#c8c6bc', } + +// The composer's fill — the app's ONE painted surface (see the note above for why +// it is the only one that can be). The brand panel step, neutralized: chalk sends +// any hex whose channels differ to the 6x6x6 colour cube, whose darkest step +// above black is rgb(95,95,95), so the authored warm #262523 paints as a MID GREY +// slab on a terminal that does 256 colours but not truecolor (Terminal.app, tmux +// without RGB, mosh, conhost). Equal channels route to the greyscale ramp +// instead, where a near-black stays near-black. Truecolor terminals lose only the +// warmth, which is invisible at this brightness. +export const inputSurface = chalk.level >= 3 ? '#262523' : '#252525' diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 56bf95f..f4dc8f3 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -36,8 +36,15 @@ import { applyEditShortcut } from '../lib/editing' import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' import { fitLines, visibleWidth } from '../lib/markdown' import { SELECTION_GLYPH } from '../lib/sessions' -import { theme } from '../lib/theme' +import { inputSurface, theme } from '../lib/theme' import { useAltScreen } from './altScreen' +import { + completedText, + isCommandInput, + matchCommands, + resolveCommand, + type SlashCommand, +} from './commands' import { VERSION } from '../lib/constants' import { activityRows, @@ -259,6 +266,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const [elapsed, setElapsed] = useState(0) const [notice, setNotice] = useState(props.initialNotice ?? null) + // The slash-command menu's highlighted index. The menu itself is derived from + // what is typed (see `menu` below) rather than stored — it is open exactly when + // the line starts with `/` and something still matches — so this is the only + // state it needs, and it resets to the top whenever the matches change. + const [menuIndex, setMenuIndex] = useState(0) // The composer's text and caret position (0..text.length), one state so // rapid keypresses between renders can't desync them. Left/right move the // caret, up/down walk the lines of a multi-line input like a normal text @@ -633,13 +645,35 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const text = raw.trim() setComposer({ text: '', cursor: 0 }) if (!text) return - if (text === '/exit' || text === '/quit') { - exit() - return + // A leading slash claims the line for the CLI. An unknown one is REFUSED, + // not forwarded: a typo'd command sent on as prose is a message you did + // not mean to send, and the agent cannot tell it from one you did. + if (isCommandInput(text)) { + const command = resolveCommand(text) + if (!command) { + setNotice(`✗ no such command: ${text.split(/\s/)[0]} · type / to see them`) + return + } + if (command.id === 'exit') { + exit() + return + } + if (command.id === 'transcript') { + setWindowed(true) + return + } + if (command.id === 'sessions') { + if (props.onFocusNav) props.onFocusNav() + else setNotice('no other sessions here · this is a single-session connect') + return + } } + // /stop is the one command that talks to the server, so it rides the async + // path below with the sends. + const stopping = resolveCommand(text)?.id === 'stop' void (async () => { try { - if (text === '/stop') { + if (stopping) { const { session: s } = await api.sessions.stop(sessionId) setNotice(null) setChatNotes((prev) => [ @@ -679,7 +713,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } })() }, - [api, exit, pump, sessionId], + [api, exit, pump, sessionId, props.onFocusNav], ) // The composer renders whenever sending is possible. @@ -692,6 +726,29 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // would strand you on the alt screen. const inputActive = (composerVisible || windowed) && focused + // The slash-command menu: the commands the typed line still matches. Derived, + // not stored, so it cannot get out of step with the input — it is open exactly + // while the line starts with `/` and something matches, and typing a character + // that matches nothing closes it (the line is then just prose, and submit will + // say so). + const menu = useMemo( + () => (composerVisible ? matchCommands(composer.text) : []), + [composerVisible, composer.text], + ) + const menuOpen = menu.length > 0 + // Clamped rather than stored-and-corrected: the matches shrink as you type, and + // an index left pointing past the end would highlight nothing. + const menuAt = Math.min(menuIndex, Math.max(0, menu.length - 1)) + // Back to the top whenever the match set changes, so the highlight is on the + // best match for what you have typed rather than wherever it was left. + useEffect(() => { + setMenuIndex(0) + }, [menu.length]) + const complete = useCallback((command: SlashCommand): void => { + const text = completedText(command) + setComposer({ text, cursor: text.length }) + }, []) + // Mouse reporting (SGR) — the browser's wheel scrolling, and ONLY the // browser's. It owns the alternate screen, whose scrollback is empty by // definition, so the wheel is useless there unless the app reads it. The chat @@ -770,19 +827,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // the notice line, which is where the app's one line of transient guidance // already lives. const browserNotice = '↑↓ scroll · → open · ← close · ctrl+r expand all · esc back to the chat' - // With nothing else to say, the chat names the screens it can open — neither - // is visible from here, so without this they are undiscoverable. Only where - // the keyboard actually reaches us: a headless `--no-input` follow (piped into - // a script or an agent) would otherwise advertise keys nothing can press. - const restingHint = isRawModeSupported - ? `ctrl+r transcript${props.onFocusNav ? ' · ctrl+j sessions' : ''}` - : null - const shownNotice = windowed - ? browserNotice - : ctrlCArmed - ? CTRL_C_QUIT_HINT - : (notice ?? restingHint) - const { viewBudget, padRows, composerRows, noticeRows } = useMemo(() => { + const shownNotice = windowed ? browserNotice : ctrlCArmed ? CTRL_C_QUIT_HINT : notice + const { viewBudget, padRows, composerRows, noticeRows, menuRows } = useMemo(() => { // Both wrapping parts of the footer are measured as the rows they will // actually OCCUPY, not as the newlines they contain: a notice ("stream // error: …") and a typed paragraph both wrap, and counting either as one @@ -799,6 +845,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ? Math.max(0, Math.min(fitLines(`· ${shownNotice}`, cols).length, free - 2)) : 0 free -= noticeRows + // The command menu, one row per match, budgeted like everything else: it + // renders INSIDE the frame, so rows it takes are rows the chat yields. In a + // terminal with no room for it the list is cut short rather than pushing the + // composer off the bottom. + const menuRows = Math.max(0, Math.min(menu.length, free - 3)) + free -= menuRows // The composer: its interior grows with the input, plus the 1-cell pad above // and below. In a terminal too short for all of it the pad goes, then the // interior shrinks toward a single row. @@ -810,7 +862,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // padding; the chat is content-sized and grows downward, so it has no window // edge to protect and takes none. const pad = windowed ? Math.max(0, Math.min(1, forContent - 1)) : 0 - return { viewBudget: forContent - pad, padRows: pad, composerRows, noticeRows } + return { viewBudget: forContent - pad, padRows: pad, composerRows, noticeRows, menuRows } }, [ rows, cols, @@ -819,6 +871,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { composerVisible, composer.text, shownNotice, + menu.length, ]) // The live tail: the in-progress response and the one activity line under @@ -1199,6 +1252,36 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if (windowed) scrollByRows(key.pageUp ? -view.capacity : view.capacity) return } + // The command menu is the INNERMOST modal, so it takes its keys before + // anything else: ↑/↓ walk it, tab/enter complete the highlighted command, + // esc dismisses it by clearing the slash that opened it. Everything else + // (typing, editing, ctrl+*) falls through to the composer below, which is + // what keeps the menu a suggestion rather than a mode you get stuck in. + if (menuOpen) { + if (key.upArrow || key.downArrow) { + 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) + return + } + if (key.tab) { + complete(menu[menuAt]) + return + } + // Enter COMPLETES rather than submits while the line is still a partial + // command ("/tr"), and submits once it names one exactly ("/transcript"): + // otherwise enter on a highlighted suggestion would refuse the very + // command the menu is pointing at. + if (key.return && !resolveCommand(composer.text)) { + complete(menu[menuAt]) + return + } + if (key.escape) { + setComposer({ text: '', cursor: 0 }) + return + } + } if (key.escape) { // Modal-first, outermost modal first: the browser is a screen of its // own, so esc closes it before anything inside it is considered. @@ -1511,13 +1594,33 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { · {shownNotice} )} - {/* The composer. Unpainted, like everything else: what marks it as the - input is the ▶ prompt and the caret, not a tint. It is pinned to the - rows budgeted for it and clips, so a terminal too short for the whole - input scrolls it rather than painting the overflow across the meta - line. */} + {/* The slash-command menu, directly above the input it completes: one + row per match, the highlighted one wearing the same cyan ▶ as every + other selection in the app. It sits inside the budgeted rows + (menuRows), so a long list is cut rather than pushing the composer + off the bottom of the frame. */} + {menuRows > 0 && ( + + {menu.slice(0, menuRows).map((command, i) => ( + + + {i === menuAt ? SELECTION_GLYPH : ' '}{' '} + {`/${command.name}`} + {` ${command.detail}`} + + + ))} + + )} + {/* The composer. It is pinned to the rows budgeted for it and clips, so a + terminal too short for the whole input scrolls it rather than painting + the overflow across the meta line. */} {composerVisible && ( + [c.name, ...(c.aliases ?? [])].some((n) => n.startsWith(typed)), + ) +} + +// The command an entered line names, or null when it names none — which the +// caller reports as an error rather than sending on. Exact match on the name or +// an alias, case-insensitively; a prefix is NOT enough here, because running the +// wrong command is worse than being told to finish typing. Pure, for tests. +export function resolveCommand(text: string): SlashCommand | null { + if (!isCommandInput(text)) return null + const typed = text.slice(1).trim().toLowerCase() + return ( + SLASH_COMMANDS.find((c) => [c.name, ...(c.aliases ?? [])].includes(typed)) ?? null + ) +} + +// The text the composer holds after tab/enter completes `highlighted`: the whole +// command, with a trailing space so the line reads as finished. Pure, for tests. +export function completedText(command: SlashCommand): string { + return `/${command.name} ` +} diff --git a/test/commands.test.ts b/test/commands.test.ts new file mode 100644 index 0000000..189cbc9 --- /dev/null +++ b/test/commands.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { + completedText, + isCommandInput, + matchCommands, + resolveCommand, + SLASH_COMMANDS, +} from '../src/ui/commands' + +describe('isCommandInput', () => { + it('claims a line only when the slash LEADS it', () => { + expect(isCommandInput('/stop')).toBe(true) + expect(isCommandInput('/')).toBe(true) + // Ordinary prose that happens to contain a slash still reaches the agent. + expect(isCommandInput('check /tmp for the log')).toBe(false) + expect(isCommandInput('what does a/b mean')).toBe(false) + expect(isCommandInput('')).toBe(false) + }) +}) + +describe('matchCommands', () => { + const names = (text: string): string[] => matchCommands(text).map((c) => c.name) + + it('offers everything for a bare slash', () => { + expect(names('/')).toEqual(SLASH_COMMANDS.map((c) => c.name)) + }) + + it('narrows by prefix', () => { + expect(names('/st')).toEqual(['stop']) + expect(names('/s')).toEqual(['stop', 'sessions']) + expect(names('/tr')).toEqual(['transcript']) + }) + + it('matches aliases but shows the canonical name', () => { + expect(names('/qu')).toEqual(['exit']) + expect(names('/quit')).toEqual(['exit']) + }) + + it('is case-insensitive', () => { + expect(names('/ST')).toEqual(['stop']) + }) + + it('offers nothing once a space commits to a command', () => { + // The first word is the command; past it there is nothing left to complete. + expect(names('/stop ')).toEqual([]) + expect(names('/stop now')).toEqual([]) + }) + + it('offers nothing for prose or an unknown prefix', () => { + expect(names('hello')).toEqual([]) + expect(names('/zzz')).toEqual([]) + }) +}) + +describe('resolveCommand', () => { + it('needs the WHOLE name, so a prefix never runs the wrong command', () => { + expect(resolveCommand('/stop')?.id).toBe('stop') + // '/s' matches two commands in the menu; running either would be a guess. + expect(resolveCommand('/s')).toBeNull() + expect(resolveCommand('/st')).toBeNull() + }) + + it('accepts aliases and surrounding whitespace', () => { + expect(resolveCommand('/quit')?.id).toBe('exit') + expect(resolveCommand('/exit')?.id).toBe('exit') + expect(resolveCommand('/stop ')?.id).toBe('stop') + expect(resolveCommand('/STOP')?.id).toBe('stop') + }) + + it('returns null for prose and for an unknown command', () => { + expect(resolveCommand('hello')).toBeNull() + expect(resolveCommand('/stpo')).toBeNull() + }) +}) + +describe('completedText', () => { + it('completes to the canonical name with a trailing space', () => { + expect(completedText(SLASH_COMMANDS[0])).toBe('/stop ') + // An alias completes to the name it stands for, not to the alias. + const exit = SLASH_COMMANDS.find((c) => c.id === 'exit') + expect(completedText(exit!)).toBe('/exit ') + }) + + it('produces text that resolves back to the same command', () => { + for (const command of SLASH_COMMANDS) { + expect(resolveCommand(completedText(command))?.id).toBe(command.id) + } + }) +}) + +describe('the command list itself', () => { + it('has no duplicate names or aliases, so a spelling means one thing', () => { + const spellings = SLASH_COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]) + expect(new Set(spellings).size).toBe(spellings.length) + }) + + it('describes every command, since the menu shows the detail', () => { + for (const command of SLASH_COMMANDS) { + expect(command.detail, command.name).toMatch(/^[a-z]/) + expect(command.detail.length, command.name).toBeGreaterThan(8) + } + }) +}) diff --git a/test/connect-render.test.ts b/test/connect-render.test.ts index af1b992..b2dc7b9 100644 --- a/test/connect-render.test.ts +++ b/test/connect-render.test.ts @@ -207,11 +207,12 @@ describe('ConnectApp — the scrollback view', () => { expect(text).toContain('─') }) - it('paints no background, so the terminal\'s own shows through', async () => { + it('paints no background on a FLUSHED row, so scrollback carries no stale fill', async () => { // A row printed into scrollback is never repainted, so a fill on it outlives // the frame that drew it: stale bands survive a resize or a shorter frame - // with nothing able to clean them up. The app therefore emits no background - // SGR at all (48;2;r;g;b truecolor, 48;5;n indexed, or 40-47/100-107). + // with nothing able to clean them up. The composer is the one painted + // surface, and it lives in the live frame — so the assertion is about the + // flushed rows specifically, not about the byte stream as a whole. const store = seededStore( [lifecycle('sandbox_ready', {}), say('hello'), say('and again')], 'waiting', @@ -219,14 +220,50 @@ describe('ConnectApp — the scrollback view', () => { 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() + // The flushed rows are everything written before the live frame's first + // cursor-hide, which is where ink starts painting the region it owns. 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 flushed = raw.slice(0, raw.indexOf('\u001B[?25l')) + expect(flushed).toContain('hello') + expect(flushed).not.toMatch(/\u001B\[[0-9;]*4[0-7]m/) + expect(flushed).not.toMatch(/\u001B\[[0-9;]*10[0-7]m/) + expect(flushed).not.toContain('48;2;') + expect(flushed).not.toContain('48;5;') + }) + + it('opens the slash-command menu as you type, and completes with tab', async () => { + const store = seededStore([lifecycle('sandbox_ready', {}), say('hi')], 'waiting') + const { stream, output } = fakeTty() + const stdin = fakeStdin() + const app = render(chat(store), { stdout: stream, stdin, patchConsole: false }) + await settle() + const before = output().length + + // A bare slash offers every command, with its description. + stdin.write('/') + await settle() + let frame = stripAnsi(output().slice(before)) + expect(frame).toContain('/stop') + expect(frame).toContain('/transcript') + expect(frame).toContain('interrupt the agent') + + // Typing narrows it to one. Measured from HERE, not from the start: the byte + // stream keeps every earlier frame, so a cumulative slice would still hold + // the full list printed a moment ago. + const beforeNarrow = output().length + stdin.write('tr') + await settle() + frame = stripAnsi(output().slice(beforeNarrow)) + expect(frame).toContain('/transcript') + expect(frame).not.toContain('/stop') + + // Tab completes the highlighted command into the input. + const beforeTab = output().length + stdin.write('\t') + await settle() + expect(stripAnsi(output().slice(beforeTab))).toContain('/transcript') + app.unmount() }) it('does not capture the mouse in the chat, so the terminal keeps the wheel', async () => { From 22810a5dfffb991bd8e459497c001a85eea8a3fe Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 19 Aug 2026 17:32:27 -0400 Subject: [PATCH 4/5] ui: close the bottom edge of the new-session input box 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. --- src/ui/SessionsApp.tsx | 48 +++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 6ef3699..c87e886 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -1100,6 +1100,11 @@ function NewSessionPane({ // minus the heading, notices, and the panel's other rows (~12); the panel // grows upward into the spacer above, so the prompt never moves. const dropdownCapacity = Math.max(3, height - 12) + // Whether the input has room for its 1-row perimeter: the three picker rows, + // the blank one above the prompt, the prompt's own two, and a pad row top AND + // bottom. All or nothing, deliberately — a pad on top with none underneath + // reads as a box that forgot to close, which is worse than no pad at all. + const inputPad = height - (PICKER_ROWS.length + 1 + 2) >= 2 ? 1 : 0 // The wrap width inside the input box: the pane minus the box's own left // and right padding (the "▶ " prompt is part of the wrapped text). const inputWidth = Math.max(8, width - COMPOSER_PAD_X * 2) @@ -1119,24 +1124,31 @@ function NewSessionPane({ // pushing the input around. // No pane padding: the input's rules span the full terminal width like // the header's and the nav's (the rows inside carry their own indents). + // The heading and the fact are DECORATION, and they are what yields when the + // terminal is short: both sit in an overflow-hidden box that shrinks to + // nothing, so the input below keeps every row it asked for — including the + // blank one under the prompt. Squeezing the input instead would eat that row + // and leave the box looking open at the bottom. - - - - What are we shipping today? - - - {/* The fact box is sized to the text (capped so long facts wrap at a - readable measure) so short facts sit centered, not left-aligned - inside a fixed column. */} - - - - {fact} + + + + + What are we shipping today? + {/* The fact box is sized to the text (capped so long facts wrap at a + readable measure) so short facts sit centered, not left-aligned + inside a fixed column. */} + + + + {fact} + + + + - {error && ✗ {error}} {starting && ✻ Starting session…} {/* The input, laid out like the chat composer: the run controls one row @@ -1151,8 +1163,14 @@ function NewSessionPane({ flexDirection="column" backgroundColor={inputSurface} alignItems="flex-start" - paddingY={1} + paddingY={inputPad} paddingX={COMPOSER_PAD_X} + // Never squeezed. The pane clips its overflow, and the input is the LAST + // child, so without this a pane too short for the content above it takes + // 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} > {PICKER_ROWS.map((r, i) => { const active = focused && openPicker === null && row === i From 5332e668a808dd684cdc333125465f8adac98289 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 19 Aug 2026 17:35:46 -0400 Subject: [PATCH 5/5] test: pin interactive rendering in the render harnesses, which CI turns off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- test/connect-render.test.ts | 21 +++++++++++++++------ test/scrollback.test.ts | 16 +++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/test/connect-render.test.ts b/test/connect-render.test.ts index b2dc7b9..d0c9237 100644 --- a/test/connect-render.test.ts +++ b/test/connect-render.test.ts @@ -75,6 +75,15 @@ function costTick(store: SessionTranscriptStore, cents: number): void { const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 40)) +// The render options every test here uses. `interactive: true` is not optional: +// ink treats CI as non-interactive (is-in-ci), and a non-interactive render +// buffers everything and emits ONE final frame — no erases, no repaints, no +// flush as it happens. Every claim in this file is about the difference +// between a flushed row and a repainted one, so the whole file measures nothing +// under CI without it. The real app pins the same flag for the same reason (see +// runConnect). +const OPTIONS = { patchConsole: false, interactive: true } as const + let seq = 0 // A claude_code assistant-message record — the shape recordToItems turns into a // ● prose row. @@ -146,7 +155,7 @@ describe('ConnectApp — the scrollback view', () => { 'waiting', ) const { stream, output } = fakeTty() - const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), patchConsole: false }) + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) await settle() // Two more repaints of the live region, so "written once" below is a real // claim about the flush rather than an artifact of a single frame. @@ -179,7 +188,7 @@ describe('ConnectApp — the scrollback view', () => { 'working', ) const { stream, output } = fakeTty() - const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), patchConsole: false }) + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) await settle() costTick(store, 50_000) await settle() @@ -198,7 +207,7 @@ describe('ConnectApp — the scrollback view', () => { const app = render(chat(store, { scrollbackBreak: true }), { stdout: stream, stdin: fakeStdin(), - patchConsole: false, + ...OPTIONS, }) await settle() app.unmount() @@ -218,7 +227,7 @@ describe('ConnectApp — the scrollback view', () => { 'waiting', ) const { stream, output } = fakeTty() - const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), patchConsole: false }) + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) await settle() app.unmount() // The flushed rows are everything written before the live frame's first @@ -236,7 +245,7 @@ describe('ConnectApp — the scrollback view', () => { const store = seededStore([lifecycle('sandbox_ready', {}), say('hi')], 'waiting') const { stream, output } = fakeTty() const stdin = fakeStdin() - const app = render(chat(store), { stdout: stream, stdin, patchConsole: false }) + const app = render(chat(store), { stdout: stream, stdin, ...OPTIONS }) await settle() const before = output().length @@ -272,7 +281,7 @@ describe('ConnectApp — the scrollback view', () => { // select/copy to the terminal. const store = seededStore([lifecycle('sandbox_ready', {}), say('hello')], 'waiting') const { stream, output } = fakeTty() - const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), patchConsole: false }) + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) await settle() app.unmount() expect(output()).not.toContain('[?1000h') diff --git a/test/scrollback.test.ts b/test/scrollback.test.ts index 1c4ce5e..2c55197 100644 --- a/test/scrollback.test.ts +++ b/test/scrollback.test.ts @@ -28,6 +28,12 @@ function fakeTty(): { stream: NodeJS.WriteStream; output: () => string } { const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 25)) +// `interactive: true` is not optional: ink treats CI as non-interactive +// (is-in-ci), and a non-interactive render buffers everything into ONE final +// frame — no erases, no repaints, no flush as it happens. These tests +// are about exactly that difference, so they measure nothing under CI without it. +const OPTIONS = { patchConsole: false, interactive: true } as const + describe(' flush — the scrollback view', () => { it('prints each settled row ONCE and never reprints it', async () => { // The invariant the whole scrollback view rests on: a flushed row is printed @@ -48,7 +54,7 @@ describe(' flush — the scrollback view', () => { h(Text, null, 'live'), ) } - const app = render(h(App), { stdout: stream, patchConsole: false }) + const app = render(h(App), { stdout: stream, ...OPTIONS }) await settle() app.unmount() const text = stripAnsi(output()) @@ -75,7 +81,7 @@ describe(' flush — the scrollback view', () => { }, []) return h(Static, { items: rows }, (row: string) => h(Text, { key: row }, row)) } - const app = render(h(App), { stdout: stream, patchConsole: false }) + const app = render(h(App), { stdout: stream, ...OPTIONS }) await settle() app.unmount() expect(stripAnsi(output()).match(/alpha/g)?.length).toBe(2) @@ -89,7 +95,7 @@ describe('useAltScreen', () => { useAltScreen(open) return h(Text, null, 'frame') } - const app = render(h(App, { open: false }), { stdout: stream, patchConsole: false }) + const app = render(h(App, { open: false }), { stdout: stream, ...OPTIONS }) await settle() expect(output()).not.toContain('[?1049h') @@ -113,7 +119,7 @@ describe('useAltScreen', () => { useAltScreen(true) return h(Text, null, 'frame') } - const app = render(h(App), { stdout: stream, patchConsole: false, interactive: true }) + const app = render(h(App), { stdout: stream, ...OPTIONS }) await settle() app.unmount() expect(out).not.toContain('[?1049') @@ -132,7 +138,7 @@ describe('useAltScreen', () => { }, [exit]) return h(Text, null, 'frame') } - const app = render(h(App), { stdout: stream, patchConsole: false }) + const app = render(h(App), { stdout: stream, ...OPTIONS }) await settle() app.unmount() await settle()