From 1d0507d9a5271f7c92a15a7f509c74f7a4328839 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sun, 26 Jul 2026 18:55:52 -0400 Subject: [PATCH] connect: scroll the chat by rows, nest tool calls, flatten the startup log The transcript viewport sliced by whole entries using estimated heights, so a message taller than the window could never be read: it appeared whole or not at all, and one scroll notch threw all of it away. The estimates also left dead space and phantom "N newer" markers. Everything on screen is now flattened to exact one-line screen rows and the window is a row range over that list, so up arrow brings a message to the top of the frame and reads down through it. Also drops the per-message padding rows to fit more of the conversation on screen. Tool calls no longer stand as their own turns: a call, its result and the collapsed fold nest under the message that produced them. Session milestones (asleep, waking, awake, cancelled) now land in the chat, so a nap between turns stops being an unexplained gap. The startup block's three-level phase tree becomes one flat ten-line log with build and setup output inline, opened with a single arrow instead of three. Live lines carry a pulsing mark. --- src/lib/markdown.ts | Bin 9273 -> 10450 bytes src/ui/ConnectApp.tsx | 1880 +++++++++++++++++--------------------- src/ui/transcriptRows.ts | 518 +++++++++++ test/connect-app.test.ts | 642 +++++++------ test/markdown.test.ts | 28 +- 5 files changed, 1783 insertions(+), 1285 deletions(-) create mode 100644 src/ui/transcriptRows.ts diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 5f3f5e03d5de498289085f7a4f538f1e76a9863c..e4cec3ae3ecfd294ea32c462c1d207e19bc69816 100644 GIT binary patch delta 1227 zcma)5&2G~`5Eir&V2L9Qf&z0uile$Nhn^zZs*NhOQVXJuRHQVb8+%-@;@D<)T_;pg zxbXny0T54s)Ds6@f;$%;ffs-o=cfhrK%^+n&iI>ezVF+wJKtY@xi!}{O2pbzGBt1} zF;5d^td$igKXjf%k%rCy4jTvC+b`hY<!w#C=-gf_%wH~_%^o$vr{ke5ro1L1p6MQMkaB+xIMpQ&zg+NVowdfmTMv zhnMT;Nf!AG$S#sDnp)_m3xWW3#Q%5{?LvFyGWN+|8VX4OdJucAg?$bR1mp}m26v~f z*oKTKo>bswt6gkV9^P0jjw?%x@bsJu!xIZgn^+qNN#iv(vr&AmtZt1DX$Dzhl#EYX zZ7R3g)E^`Y>~WnvWfF|#>NJwZt{%tLS`E&v;`O7)Bl4AkK`?Hb>Coo>8bJVHM6Qqt zw$ummRQepDarsL7)Z%7x#4#!B4tuF(radJ=DWv4(B=@Z0I4lmJ_C$)H@ok2&N7saIw?=h_zH80=y-u0p z_o6fUM}YEr{l5UMXKmQnfNIqmTGrU&>00D|j5+L!^!YdtbQdPUSf~9tyjzjD`0?QQ F&L30_pnU)U delta 275 zcmX|*u}uR(5JdwcAt->9pCEw%IYgoY2u%alcyG+D?Au+<%z4idWG=uIg5-!cTmqG_ zcj7$Zpa0+g$Jx)z_xa>w<2k1&hUDE!;Y}~6Qw*nT?-)yo4Oq=LWL!aFhKp@5#qFrR z8aHn->m7bSzmUDvu$p1q*$I|}AG1(Oh0#C%vrmi#yV$UTJ8}je)R-#7B?#{=t3H52 zRrD2VI*r@!Cif^qDh5fZ>WQ7>WIl%Hv2=gzO(tz{!}i5?zz7w)jHbiQ+3xC~*Yk@% Df|FaK diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index d998b59..ba8e510 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -15,7 +15,6 @@ import { } from '@ellipsis-dev/sdk/stream' import { cacheTierLabel, - clampLines, collapseToolRuns, foldCosts, lifecycleText, @@ -25,9 +24,7 @@ import { sandboxOutputStep, sandboxPhaseLabel, statusActivityText, - oneLine, type CCEvent, - type ItemKind, type SessionTranscriptStore, type TranscriptItem, } from '@ellipsis-dev/sdk/store' @@ -35,10 +32,30 @@ import { ApiClient, ApiError } from '../lib/api' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' -import { hasMarkdown, renderMarkdown, visibleWidth } from '../lib/markdown' +import { fitLines } from '../lib/markdown' import { SELECTION_GLYPH } from '../lib/sessions' import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' import { VERSION } from '../lib/constants' +import { + activityRows, + anchorAt, + anchorIndex, + contentWidth, + entryRange, + GUTTER_COLS, + isCollapsible, + itemRows, + layOutItems, + LIVE_GLYPH, + MESSAGE_PAD, + pendingMessageRows, + rowViewport, + snapToEntry, + spacerRow, + type RowSpan, + type ScrollAnchor, + type TranscriptRow, +} from './transcriptRows' // The interactive `agent session connect` UI, modelled on Claude Code: a // committed transcript that groups tool calls with their results and spaces @@ -92,8 +109,10 @@ export interface ConnectAppProps { // stdout's rows/columns. paneWidth?: number paneHeight?: number - // Blank rows above the first line. The solo app keeps TOP_PAD's slack - // against terminal row-accounting quirks; a pane host owns its own edges. + // 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. @@ -121,25 +140,36 @@ function isWorkingStatus(status: string): boolean { return ['scheduled', 'starting', 'working', 'retrying'].includes(status) } -// Blank rows rendered above the app's first line: two of visual breathing -// room above the ✻ startup header, plus two of sacrificial slack — see the -// termRows comment in ConnectApp for what the slack absorbs. -const TOP_PAD = 4 +// 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. ink's minHeight -// counts padding, so the panel's own minHeight and the viewport budget both -// use this plus 2 (the pad above and below). -const COMPOSER_INTERIOR_ROWS = 3 +// 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. +const COMPOSER_INTERIOR_ROWS = 1 // Horizontal breathing room inside the composer panel — wider than the 1-cell // vertical pad so the caret and text start well clear of the panel edge. const COMPOSER_PAD_X = 2 -// The pad inside a chat message's panel, all four sides — the text sits one -// cell off the tint's edge, like the composer's interior. Counted by the -// viewport height estimates (two extra rows per message) and subtracted from -// the wrap width (two columns). -const MESSAGE_PAD = 1 +// Rows one wheel notch moves. Terminals report a notch per tick, and one row +// per tick makes a trackpad feel like it's dragging through treacle. +const WHEEL_ROWS = 3 + +// 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 +// don't visibly beat against each other. +const PULSE_MS = 700 + +// Columns the composer's text actually gets: the panel's horizontal pad on +// both sides, then the prompt glyph and its trailing space. +function composerTextCols(cols: number): number { + return Math.max(8, cols - COMPOSER_PAD_X * 2 - 2) +} // One local send awaiting server acknowledgement: messageId is null while the // POST is in flight, then the created SessionMessage's id (protocol v2 §4.2) — @@ -231,11 +261,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // Lines opened in place with → while highlighted: a grp:* fold expands into // its tool calls, a clamped long body un-clamps. ← closes them again. const [openedKeys, setOpenedKeys] = useState>(new Set()) - // The transcript viewport: the key of the entry pinned to the top of the - // window, or null to follow the bottom (the default — new content stays in - // view). The scroll wheel / trackpad moves it; moving the ↑/↓ highlight out - // of frame snaps it so the highlighted entry comes back into view. - const [scrollKey, setScrollKey] = useState(null) + // The transcript viewport: the ROW pinned to the top of the window (as an + // entry + row offset, so appends and re-wraps can't slide it), or null to + // follow the bottom — the default, so streamed content stays in view. The + // 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 @@ -302,28 +333,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // The sandbox startup timeline, derived from the lifecycle records of the // latest start (a session_starting wake/retry or sandbox_starting record - // resets it, so a wake tells a fresh story): dim session notes, one step - // per provisioning phase — opened by its sandbox_phase `started` - // transition, closed with its cache-tier/duration note — accumulating the - // log lines of its sandbox_output chunks, plus the sandbox_ready summary. - // Completed steps STACK as ✓ lines (the design-doc §3 timeline) instead of - // rewriting one line in place; the live step ticks under them with its - // last RUNNING_TAIL_LINES log lines as a dim tail beneath it. Highlighting the block (↑ from the composer) and - // pressing → opens the step list, and arrow keys drill into a step's logs. - // The block persists after startup as the durable trace (the sandbox_ready - // transcript notice is suppressed below in its favour). + // resets it, so a wake tells a fresh story): a headline tracking the + // session's state, plus ONE FLAT LOG of every milestone and every line of + // build/setup output, newest last. While the session comes up the block + // shows the tail of that log; once ready it collapses to the bare headline, + // and → while highlighted re-opens the log to re-read it. The block persists + // after startup as the durable trace (the sandbox_ready transcript notice is + // suppressed below in its favour). const sandbox = useMemo( () => deriveSandboxState(snapshot.records, props.minRenderFeedSeq), [snapshot.records, props.minRenderFeedSeq], ) - const [sandboxOpen, setSandboxOpen] = useState(false) - const [stepCursor, setStepCursor] = useState(0) - const [stepLogsOpen, setStepLogsOpen] = useState(false) - // Once the session settles the block collapses to the bare headline; → - // while highlighted re-reveals the config + sandbox child lines (and → - // again drills into the phases via sandboxOpen). Live starts always show - // the whole hierarchy — there's nothing to collapse until it's done. - const [sandboxDetails, setSandboxDetails] = useState(false) + // Whether a SETTLED block is showing its log again (→ opens it, ← closes it). + // A live start always shows the log — there is nothing to collapse until the + // session is up. + const [sandboxLogOpen, setSandboxLogOpen] = useState(false) // Bodies of the server's PENDING inbox messages — the durable queued signal. const serverQueued = useMemo( @@ -463,6 +487,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return () => clearInterval(t) }, [working]) + // The heartbeat behind every live ⏺ mark: one timer for the whole app, so + // each pulsing glyph breathes in step instead of drifting out of phase. It + // runs only while something is actually in flight — a still ⏺ on a settled + // transcript would be a lie, and an idle interval would wake the render loop + // for nothing. Reset on the way in so a new turn starts bright. + const [pulseOn, setPulseOn] = useState(true) + useEffect(() => { + if (!working) { + setPulseOn(true) + return + } + const t = setInterval(() => setPulseOn((on) => !on), PULSE_MS) + return () => clearInterval(t) + }, [working]) + // The tool calls executing right now (an unmatched tool_use in the committed // transcript — see pendingToolCalls), with a per-burst seconds ticker so a // long Bash call reads "Running Bash(pytest…)… (34s)" instead of dead air. @@ -604,12 +643,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return { visible: out, indented: indentedKeys } }, [items, expanded, pendingTools, openedKeys]) - // Everything ↑/↓ can highlight, top to bottom: the sandbox startup block - // (when it renders), then the transcript lines. Navigation tracks keys, not - // indices, so streamed appends don't shift the highlight. Each entry also - // carries an estimated on-screen height (rows), which drives the viewport: - // only the slice that fits the window renders, scrolled by wheel/trackpad - // and snapped to the highlight. const infraActivity = statusActivityText(statusWord) // The startup story has settled: the headline is final ("Session ready!"), // nothing is live. This is when the block collapses to the bare headline. @@ -617,160 +650,275 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { useEffect(() => { // A fresh start (wake/retry) re-opens the live hierarchy and re-arms the // collapse for when it settles again. - if (!sandboxSettled) setSandboxDetails(false) + if (!sandboxSettled) setSandboxLogOpen(false) }, [sandboxSettled]) - const entries = useMemo(() => { - const width = Math.max(20, cols - 3) - const keys: string[] = [] - const heights: number[] = [] - const byKey = new Map() + // How the pane's rows are divided: the footer (notice + composer + meta + // line) is fixed, the chat window takes the rest, and the top padding is the + // give — it shrinks, to nothing if it must, so the frame always fits. + // + // Fitting is not cosmetic: an over-tall frame scrolls ink's render region and + // smears stale rows up the terminal. So the window's budget is whatever is + // left AFTER the footer, never a floor that could exceed the pane, and the + // window itself renders exactly that many rows (see rowViewport). + 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 */ + // 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 — + // the important half of "stream error: …" is the front). + let free = rows - bottomSlack - fixed + // A pane with no room for chat + composer + notice drops the notice + // entirely: overflowing the frame would smear the whole app. + const noticeRows = notice + ? Math.max(0, Math.min(fitLines(`· ${notice}`, 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. + 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 + 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)) + return { viewBudget: forContent - pad, padRows: pad, composerRows, noticeRows } + }, [ + rows, + cols, + bottomSlack, + topPad, + composerVisible, + composer.text, + notice, + props.hideMetaLine, + ]) + + // The live tail: the in-progress response and the one activity line under + // it. Three distinct, factual signals — never whimsy — each rendered on the + // block it describes, not above the composer: + // - `generating`: the model is streaming tokens (delta frames flowing) — + // the ✻ line under the streamed prose, with elapsed + token count. + // - a running tool: a committed tool call awaits its result — a ✻ line + // attached to the burst it belongs to ("Ran 2 shell commands" then the + // live third), naming the tool and ticking its own timer. `hug` drops the + // spacer so it reads as part of that burst. + // - the fallback: a turn is in flight but nothing else says so — the + // harness-boot dead air (~15-20s before Claude Code's first event) and a + // running turn's between-records lull. Without it a send looks like the + // app hung. Gated on the TURN, not the session status: a bare interactive + // session reads 'working' while it waits for your first message, and + // narrating that would claim work that isn't happening. + // (`infraActivity` is the fourth signal, and lives in the startup block at + // the top, where a startup message belongs.) + const liveTail = useMemo(() => { + const liveText = snapshot.liveText + const liveTokens = snapshot.liveOutputTokens + const generating = statusWord === 'working' && (liveText !== '' || liveTokens != null) + const runningTool = statusWord === 'working' && !generating && pendingTools.length > 0 + // Whether the activity line hugs the block above it: in expanded mode the + // pending ● call itself is the last line; collapsed, when the trailing + // fold ("Ran N …", key grp:*) — or an opened fold's trailing tool line — + // is the same burst the pending call belongs to. + const last = visible[visible.length - 1] + const hug = expanded + ? pendingTools.length > 0 + : last != null && + (last.key.startsWith('grp:') || last.kind === 'tool' || last.kind === 'tool_result') + if (generating) { + return { + text: liveText, + label: 'Generating…', + tick: 'elapsed' as const, + suffix: liveTokens != null ? `· ↓ ${formatTokens(liveTokens)} tokens` : '', + // The ⏺ line sits directly under the prose it describes. + hug: liveText !== '', + nested: false, + } + } + if (runningTool) { + const label = + pendingTools.length === 1 + ? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}…` + : `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})…` + // A running tool call nests under the message that made it, in the + // same place its ⎿ result will land. + return { text: '', label, tick: 'tool' as const, suffix: '', hug, nested: true } + } + if (working && !infraActivity && (awaitingAgent !== null || sendPending)) { + return { + text: '', + label: `${awaitingAgent === 'boot' ? 'Starting the agent' : 'Working'}…`, + tick: 'elapsed' as const, + suffix: '', + hug: false, + nested: false, + } + } + return { text: '', label: '', tick: 'elapsed' as const, suffix: '', hug: false, nested: false } + }, [ + snapshot.liveText, + snapshot.liveOutputTokens, + statusWord, + pendingTools, + visible, + expanded, + working, + infraActivity, + awaitingAgent, + sendPending, + ]) + + // EVERY row the chat window can show, top to bottom: the startup block, the + // committed transcript, then the tail that only exists while a turn is live + // (accepted sends, the streaming response, the activity line, queued sends). + // The tail rides in the same list rather than rendering below the window, so + // it can't overflow the frame — and so scrolling up through it works like + // scrolling up through anything else. + const allRows = useMemo(() => { + const out: TranscriptRow[] = [] if (infraActivity || sandbox) { - keys.push('sandbox') - heights.push( - sandboxBlockRows(sandbox, sandboxSettled, sandboxDetails, sandboxOpen, stepLogsOpen, stepCursor), + out.push( + ...sandboxRows({ + sandbox, + infraActivity, + settled: sandboxSettled, + expanded: sandboxLogOpen, + cols, + }), ) } - for (const item of visible) { - keys.push(item.key) - const isMessage = item.kind === 'user' || item.kind === 'assistant' - // An opened fold's indented children lose 2 columns to the indent; - // a message panel loses its horizontal pad on both sides. - const itemWidth = indented.has(item.key) - ? Math.max(20, width - 2) - : isMessage - ? Math.max(20, width - MESSAGE_PAD * 2) - : width - // Markdown is resolved HERE, once, so the height estimate and the - // rendered row measure the exact same (pre-wrapped) text. - const shown = withRenderedMarkdown(item, itemWidth) - byKey.set(item.key, shown) - heights.push( - estimateItemRows(shown, itemWidth, !expanded && !openedKeys.has(item.key)) + - // The message panel's vertical pad rows (above and below the body). - (isMessage ? MESSAGE_PAD * 2 : 0), + // Tool activity is nested under the message that produced it (layOutItems + // decides what hangs off what), so a call and its result read as work the + // agent did mid-message rather than as turns of their own. + for (const placed of layOutItems(visible, { indentedKeys: indented })) { + out.push( + ...itemRows(placed.item, cols, { + indent: placed.indent, + nested: placed.nested, + attach: placed.attach, + clamp: !expanded && !openedKeys.has(placed.item.key), + }), ) } - return { keys, heights, byKey } + // Sends the agent has TAKEN (delivered, echo record still in flight): + // 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 })) + } + if (liveTail.text) { + out.push(...pendingMessageRows('live', liveTail.text, cols, { gutter: '' })) + } + if (liveTail.label) { + out.push( + ...activityRows( + 'live:act', + liveTail.label, + liveTail.tick, + liveTail.suffix, + cols, + liveTail.hug, + liveTail.nested, + ), + ) + } + for (const q of inFlightSends.filter((q) => q.state !== 'accepted')) { + out.push( + ...pendingMessageRows(q.key, q.text, cols, { + gutter: '◆', + dim: true, + right: q.state === 'sending' ? '(sending…)' : '(queued…)', + }), + ) + } + return out }, [ infraActivity, sandbox, + sandboxSettled, + sandboxLogOpen, visible, indented, - sandboxSettled, - sandboxDetails, - sandboxOpen, - stepLogsOpen, - stepCursor, expanded, openedKeys, - cols, - ]) - // Everything ↑/↓ can actually land on: the entry list minus turn summaries - // ("turn complete · 3s · $0.03"), which are informational trailers, not - // content — the selection walk skips them (they still render and scroll). - const navKeys = useMemo( - () => - entries.keys.filter( - (k) => k === 'sandbox' || entries.byKey.get(k)?.kind !== 'summary', - ), - [entries], - ) - - // Rows available to the transcript viewport: the window minus the shell - // row, top padding, the footer (composer + meta + queued + notice), the - // live-activity reserve while the agent works, and the two possible - // "… N above/below" indicator rows. Heights are estimates, so this leans - // conservative rather than overflow the window into scrollback. - const viewBudget = useMemo(() => { - const width = Math.max(20, cols - 3) - // The composer panel: a 3-row minimum interior, growing with a taller - // multi-line input, plus the 1-cell pad above and below it. No rules to - // account for — the tint is the frame. - const composerRows = composerVisible - ? Math.max(COMPOSER_INTERIOR_ROWS, composer.text.split('\n').length) + 2 - : 0 - // Live prose wraps inside the same 80% column its committed form uses; - // its message panel adds MESSAGE_PAD rows above and below. - const liveWidth = Math.max(20, Math.floor(width * 0.8)) - const liveReserve = working - ? 2 + - (snapshot.liveText - ? Math.ceil(snapshot.liveText.length / liveWidth) + 1 + MESSAGE_PAD * 2 - : 0) - : 0 - // The in-flight sends render inside the transcript area (below the - // slice), so their rows come out of the viewport budget: spacer + - // wrapped lines + the message panel's pad rows each. - const queuedReserve = inFlightSends.reduce( - (acc, q) => - acc + - 1 + - MESSAGE_PAD * 2 + - q.text.split('\n').reduce((a, l) => a + Math.max(1, Math.ceil(l.length / width)), 0), - 0, - ) - const footerRows = - (props.hideMetaLine ? 0 : 1) + (notice ? 1 : 0) + composerRows + 1 /* footer margin */ - return Math.max(3, rows - bottomSlack - topPad - footerRows - liveReserve - queuedReserve - 2) - }, [ - rows, - cols, - bottomSlack, - topPad, - composerVisible, - composer.text, - working, - snapshot.liveText, - notice, inFlightSends, - props.hideMetaLine, + liveTail, + cols, ]) - // The viewport slice for a given scroll anchor (pure math in viewportSlice; - // a stale/missing scroll key falls back to following the bottom). - const sliceFor = useCallback( - (anchorKey: string | null): { start: number; end: number } => { - if (anchorKey !== null) { - const idx = entries.keys.indexOf(anchorKey) - if (idx >= 0) return viewportSlice(entries.heights, viewBudget, { type: 'top', index: idx }) - } - return viewportSlice(entries.heights, viewBudget, { type: 'bottom' }) - }, - [entries, viewBudget], - ) - - // Wheel/trackpad scroll by whole entries; scrolling down to the newest - // entry re-pins the viewport to the bottom so new content follows again. - const wheelScroll = useCallback( + // Everything ↑/↓ can land on, top to bottom: the entries with rows on the + // list, minus turn summaries ("turn complete · 3s · $0.03") — informational + // trailers, not content, so the walk skips them (they still render and + // scroll) — and minus the live tail, which moves under you as it streams. + const navKeys = useMemo(() => { + const skip = new Set(visible.filter((i) => i.kind === 'summary').map((i) => i.key)) + const seen = new Set() + const out: string[] = [] + for (const row of allRows) { + if (skip.has(row.entryKey) || row.entryKey.startsWith('live')) continue + if (seen.has(row.entryKey)) continue + seen.add(row.entryKey) + out.push(row.entryKey) + } + return out + }, [allRows, visible]) + + // 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. + const view = useMemo(() => { + const anchor = scrollAnchor ? anchorIndex(allRows, scrollAnchor) : null + return rowViewport(allRows.length, viewBudget, anchor) + }, [allRows, viewBudget, scrollAnchor]) + + // Move the window by `delta` ROWS. Reaching the last row re-pins it to the + // bottom, so streamed content follows again. + const scrollByRows = useCallback( (delta: number): void => { - const cur = sliceFor(scrollKey) - const newStart = Math.max(0, Math.min(cur.start + delta, entries.keys.length - 1)) - const next = viewportSlice(entries.heights, viewBudget, { type: 'top', index: newStart }) - setScrollKey(next.end >= entries.keys.length ? null : entries.keys[next.start]) + const next = view.start + delta + // Reaching the last screenful re-pins to the bottom, so streamed content + // follows again instead of the window sitting one row short of it. + if (next >= allRows.length - view.capacity) setScrollAnchor(null) + else setScrollAnchor(anchorAt(allRows, Math.max(0, next))) }, - [sliceFor, scrollKey, entries, viewBudget], + [allRows, view], ) - // Snap the viewport so the given entry is in frame: above the window it - // becomes the top edge, below it becomes the bottom edge (bottom-pinned - // when it's the newest entry). Keyed, because navigation walks navKeys - // (which skips unselectable entries) while the viewport slices entries. + // Snap the window so a highlighted entry is readable: entering from above, + // its first line goes to the top of the frame; from below, it aligns to the + // bottom edge. See snapToEntry. const ensureVisible = useCallback( (key: string): void => { - const idx = entries.keys.indexOf(key) - if (idx < 0) return - const cur = sliceFor(scrollKey) - if (idx < cur.start) { - setScrollKey(entries.keys[idx]) - } else if (idx >= cur.end) { - if (idx >= entries.keys.length - 1) setScrollKey(null) - else { - const snapped = viewportSlice(entries.heights, viewBudget, { type: 'end', index: idx }) - setScrollKey(entries.keys[snapped.start]) - } - } + const target = snapToEntry(allRows, key, view, view.capacity) + if (target === null) return + const range = entryRange(allRows, key) + // Snapping to the newest entry means following the bottom again, so new + // content keeps arriving in view. + if (range && range.last >= allRows.length - 1) setScrollAnchor(null) + else setScrollAnchor(anchorAt(allRows, target)) + }, + [allRows, view], + ) + + // An entry too tall for the window is scrolled THROUGH before the highlight + // leaves it: while part of it is still out of frame in the direction you're + // heading, ↑/↓ move the window a row instead of jumping to the next entry. + // Returns whether it handled the keypress. + const revealMore = useCallback( + (key: string, delta: number): boolean => { + const range = entryRange(allRows, key) + if (!range) return false + const more = delta < 0 ? range.first < view.start : range.last >= view.end + if (!more) return false + scrollByRows(delta) + return true }, - [sliceFor, scrollKey, entries, viewBudget], + [allRows, view, scrollByRows], ) const insertAtCursor = useCallback((ch: string): void => { @@ -789,23 +937,24 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if (ch && MOUSE_SEQ_RE.test(ch)) { let delta = 0 for (const m of ch.matchAll(/\[<(\d+);\d+;\d+[Mm]/g)) { - if (m[1] === '64') delta -= 1 - else if (m[1] === '65') delta += 1 + if (m[1] === '64') delta -= WHEEL_ROWS + else if (m[1] === '65') delta += WHEEL_ROWS } - if (delta !== 0) wheelScroll(delta) + if (delta !== 0) 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. + if (key.pageUp || key.pageDown) { + scrollByRows(key.pageUp ? -view.capacity : view.capacity) return } if (key.escape) { - // Modal-first: an open sandbox panel closes, then transcript - // navigation drops back to the composer, then esc leaves the pane. - if (sandboxOpen) { - setSandboxOpen(false) - setStepLogsOpen(false) - return - } + // Modal-first: transcript navigation drops back to the composer, then + // esc leaves the pane. if (navKey !== null) { setNavKey(null) - setScrollKey(null) + setScrollAnchor(null) return } // Hosted: hand focus to the session nav (stopping is the composer's @@ -820,8 +969,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } // 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. (The sandbox step list is opened by - // highlighting the startup block with ↑ and pressing →.) + // scrolls the transcript. if (key.ctrl && ch === 's') { const next = !mouseCapture setMouseCapture(next) @@ -832,32 +980,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ) return } - if (sandboxOpen) { - if (key.upArrow) { - setStepCursor((c) => Math.max(0, c - 1)) - return - } - if (key.downArrow) { - setStepCursor((c) => Math.min(Math.max(0, (sandbox?.steps.length ?? 0) - 1), c + 1)) - return - } - if (key.rightArrow) { - setStepLogsOpen(true) - return - } - if (key.leftArrow) { - // ← with logs open hides them; with logs hidden it backs out of - // the panel (to the nav highlight if that's how it was opened). - if (stepLogsOpen) setStepLogsOpen(false) - else setSandboxOpen(false) - return - } - } else if (navKey !== null) { - // Transcript navigation: ↑/↓ walk the lines (snapping the viewport - // so the highlight stays in frame), →/enter opens the highlighted - // one, ← closes it, typing drops back to the composer. + if (navKey !== null) { + // Transcript navigation: ↑/↓ walk the entries, snapping the window so + // the highlighted one is readable; →/enter opens the highlighted one, + // ← closes it, typing drops back to the composer. const idx = navKeys.indexOf(navKey) if (key.upArrow) { + // A message taller than the window is READ before it is left: ↑ + // scrolls up inside it while any of it is still below the frame, + // and only moves to the previous entry once its top is on screen. + if (revealMore(navKey, -1)) return const target = idx === -1 ? navKeys.length - 1 : Math.max(0, idx - 1) if (navKeys.length > 0) { setNavKey(navKeys[target]) @@ -866,9 +998,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return } if (key.downArrow) { + if (revealMore(navKey, 1)) return if (idx === -1 || idx >= navKeys.length - 1) { setNavKey(null) - setScrollKey(null) + setScrollAnchor(null) } else { setNavKey(navKeys[idx + 1]) ensureVisible(navKeys[idx + 1]) @@ -877,16 +1010,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } if (key.rightArrow || key.return) { if (navKey === 'sandbox') { - // Settled, → drills one level at a time: bare headline → the - // config + sandbox lines → the phase panel. Live, the hierarchy - // is already showing, so → goes straight to the panel. - if (sandboxSettled && !sandboxDetails) { - setSandboxDetails(true) - } else { - setSandboxOpen(true) - setStepLogsOpen(false) - setStepCursor(Math.max(0, (sandbox?.steps.length ?? 0) - 1)) - } + // One level, one keystroke: → shows the startup log again on a + // settled block (a live one is already showing it). + setSandboxLogOpen(true) } else { const item = visible.find((i) => i.key === navKey) if (item && (navKey.startsWith('grp:') || isCollapsible(item))) { @@ -899,7 +1025,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // ← closes the thing opened in place; with nothing open it's inert // (the session nav lives BELOW the composer — ↓ walks to it). if (navKey === 'sandbox') { - setSandboxDetails(false) + setSandboxLogOpen(false) } else if (openedKeys.has(navKey)) { setOpenedKeys((prev) => { const next = new Set(prev) @@ -911,7 +1037,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } if (ch && !key.ctrl && !key.meta && composerVisible) { setNavKey(null) - setScrollKey(null) + setScrollAnchor(null) insertAtCursor(ch) } return @@ -922,7 +1048,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if (!composerVisible) { if (key.upArrow && navKeys.length > 0) { setNavKey(navKeys[navKeys.length - 1]) - setScrollKey(null) + setScrollAnchor(null) } else if (key.downArrow && props.onFocusNav) { props.onFocusNav() } @@ -946,7 +1072,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if (up !== null) setComposer((c) => ({ ...c, cursor: up })) else if (navKeys.length > 0) { setNavKey(navKeys[navKeys.length - 1]) - setScrollKey(null) + setScrollAnchor(null) } return } @@ -980,32 +1106,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { { isActive: inputActive }, ) - // Render the transcript from state (not ) so ctrl+r can re-expand - // committed blocks. Collapsed (the default), runs of consecutive tool - // activity fold into one "Ran N shell commands" line, Claude-Code-app-style; - // ctrl+r restores the full ● call / ⎿ result blocks (and un-clamps long - // bodies). In-flight calls are excluded from the collapsed fold — they render - // as the live "Running …" line appended right below (see runningTool), so the - // fold only counts what actually ran. The pieces are memoized on - // [items, expanded] (pendingTools derives from items), so the elapsed-second - // ticks reuse the same elements and don't re-lay-out the transcript. - // The viewport slice actually rendered this frame; atBottom means the - // newest entry is in frame (live activity lines render only then). - const slice = useMemo(() => sliceFor(scrollKey), [sliceFor, scrollKey]) - const atBottom = slice.end >= entries.keys.length - // Whether the live "Running …" line should hug the block above it: in - // expanded mode the pending ● call itself is the last line; collapsed, - // when the trailing fold ("Ran N …", key grp:*) — or an opened fold's - // trailing tool line — is the same burst the pending call belongs to. - // Otherwise the line opens its own block. - const lastVisible = visible[visible.length - 1] - const runningHug = expanded - ? pendingTools.length > 0 - : lastVisible != null && - (lastVisible.key.startsWith('grp:') || - lastVisible.kind === 'tool' || - lastVisible.kind === 'tool_result') - // The persistent footer status line: status · running spend · model · // session id (the dashboard link) · agent config (when the session has // one) · CLI version. Per-step costs live on the transcript's metadata @@ -1034,33 +1134,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { : plain.length < cols ? plain : metaParts(`${sessionId.slice(0, 20)}…`).join(' · ') - // Three distinct, factual activity signals — never whimsy. All render IN the - // transcript, on the block they describe, not above the composer: - // - `infraActivity`: the sandbox is spawning/waking (scheduled/starting/ - // retrying). Shown at the TOP, under the banner, where a startup message - // belongs — there's no conversation yet. - // - `generating`: the model is streaming tokens (delta frames flowing) — - // the ✻ line under the streamed prose, with elapsed + token count. - // - `runningTool`: a committed tool call awaits its result — a ✻ line - // attached to the tool burst it belongs to ("Ran 2 shell commands" then - // the live third), naming the tool and ticking its own timer (generating - // wins if both somehow read true; the model can't stream past an - // unresolved call). - const liveText = snapshot.liveText - const liveTokens = snapshot.liveOutputTokens - const generating = statusWord === 'working' && (liveText !== '' || liveTokens != null) - // The live lines' ticking readouts render in the right-hand metadata - // column (like every other duration), so the left side is just the label. - const generatingBits = [ - humanDuration(elapsed), - ...(liveTokens != null ? [`↓ ${formatTokens(liveTokens)} tokens`] : []), - ].join(' · ') - const runningTool = statusWord === 'working' && !generating && pendingTools.length > 0 - const runningToolLabel = - pendingTools.length === 1 - ? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}` - : `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})` - return ( // Hosted panes pin BOTH dimensions: without the width the root sizes to // its widest child (the unwrapped meta line) and smears rows across the @@ -1077,349 +1150,77 @@ 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. */} - {topPad > 0 && } + {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 startup story, session-first, at most three levels deep: - ✻ Session starting… - ✻ Sandbox starting… - ✓ Preparing image · incremental build · 3.4s - ✻ Running setup… - While in progress the whole hierarchy shows, the live level ticking - with its log tail. Once ready it collapses to the bare headline; - highlighting it (↑ from the composer) and pressing → reveals the - config + sandbox lines, → again opens the phase panel, →/← on a - phase shows/hides its logs. It is the viewport's first entry, so - it scrolls out of frame like any line. */} - {(infraActivity || sandbox) && entries.keys[0] === 'sandbox' && slice.start === 0 && ( - - {/* The conversation's opening line: where it lives. Plain text — - an OSC 8 hyperlink here gets broken by ink's wrapping and - swallows the label; the clickable dashboard link lives in the - footer meta line. */} - - ✦ Connected to ellipsis.dev - - {/* The startup story sits on the same lifted panel chat messages - use (padded tint, no rule), so it reads as a block in the - conversation rather than bare canvas text. Highlighting it - lightens the WHOLE panel to the active surface, exactly like a - selected transcript line. */} - - {/* Level 1: the session headline. The selection glyph replaces the - mark while highlighted (same 1-char slot), so the header never - shifts; the highlight is the panel-wide active surface, never - inverse. */} - - {navKey === 'sandbox' ? ( - {SELECTION_GLYPH} - ) : sandbox?.done && !infraActivity ? ( - - ) : ( - - )}{' '} - {/* The settled headline ("Session ready!") reads bold in the - default (white) foreground over the dim trace beneath it; - while starting it stays dim like the rest of the block. */} - - {/* A live status word overrides a stale done-headline: on a - wake the status flips before the new session_starting - record lands, and "Session ready!" must not linger. */} - {sandbox?.done && !infraActivity - ? sandbox.headline - : `${(!sandbox || sandbox.done ? (infraActivity ?? 'Session starting') : sandbox.headline).replace(/…$/, '')}… (${humanDuration(elapsed)})`} - + {/* 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 + can render past the frame and every line on screen is scrollable. + Dim markers count what is out of frame above/below, and they sit + inside the budget so they never push a row out. */} + + {view.showAbove && ( + + {` ↑ ${view.hiddenAbove} more line${view.hiddenAbove === 1 ? '' : 's'} above`} - {/* Levels 2+3: the config line, the sandbox line and its phases. - While starting the whole live hierarchy shows; once settled the - block collapses to the bare headline — → while highlighted - reveals level 2 (config + sandbox summary), → again opens the - phases. */} - {(!sandboxSettled || sandboxDetails) && - sandbox && (sandbox.configName || sandbox.sandboxLine) && ( - - {sandbox.configName && ( - - {' '} - {' '} - - Using {sandbox.configName} - {sandbox.configCommitSha - ? ` @ ${sandbox.configCommitSha.slice(0, 7)}` - : ''} - - - )} - {sandbox.sandboxLine && ( - - {' '} - {sandbox.sandboxDone ? ( - - ) : ( - - )}{' '} - {oneLine(sandbox.sandboxLine, 110)} - - )} - {/* Level 3, the phases: live starts show them; a settled block - keeps them behind the second → (the open panel). */} - {(!sandboxSettled || sandboxOpen) && sandbox.steps.map((step, i) => { - const running = step.status === 'running' && !sandbox.sandboxDone - const cursor = Math.min(stepCursor, sandbox.steps.length - 1) - const selected = sandboxOpen && i === cursor - const logLines = running - ? step.lines.slice(-RUNNING_TAIL_LINES) - : step.lines.slice(-FINISHED_LOG_LINES) - const hidden = step.lines.length - logLines.length - const showLogs = running || (selected && stepLogsOpen) - const logIndent = step.child ? ' ' : ' ' - const mark = - step.status === 'failed' ? ( - - ) : running ? ( - - ) : ( - - ) - return ( - - {/* The cursor column is always reserved (a space when - unselected), so opening the panel never shifts the - rows; the selected phase sits on the active surface - like the transcript highlight. Background on the - Box, not the Text — nested Texts inherit theirs from - ink's Box context and would repaint canvas over a - Text-level background. */} - - - {step.child ? ' ' : ' '} - {selected ? SELECTION_GLYPH : ' '} {mark}{' '} - - {oneLine(sandboxStepLine(step), 108)} - - {sandboxOpen && step.lines.length > 0 && ( - - {' '} - ({step.lines.length} log line{step.lines.length === 1 ? '' : 's'}) - - )} - - - {/* A running step's live tail always shows (the last - RUNNING_TAIL_LINES lines, ticking as chunks land); - a finished step's logs stay behind →. Indented two - columns past the step's label so the lines read as - its children, headed by an elision line when more - scrolled past. */} - {showLogs && hidden > 0 && ( - - {logIndent}… +{hidden} earlier line{hidden === 1 ? '' : 's'} - - )} - {(showLogs ? logLines : []).map((l, j) => ( - - {logIndent} - {oneLine(l, 100)} - - ))} - - ) - })} - - )} - - - )} - {/* The transcript viewport grows through the middle of the terminal, - pinning the composer + meta to the bottom edge (flexGrow fills the - slack). Only the slice that fits the window renders; dim markers - show what's out of frame above/below. Row heights are estimates, so - an overshooting slice clips here (overflow) instead of squashing - the footer (the composer's borders collapse onto its prompt row). */} - - {slice.start > 0 && ( - … {slice.start} earlier (scroll or ↑) - )} - {entries.keys.slice(slice.start, slice.end).map((k) => { - if (k === 'sandbox') return null - const item = entries.byKey.get(k) - if (!item) return null - return ( - - ) - })} - {!atBottom && ( - … {entries.keys.length - slice.end} newer (scroll or ↓) )} - {/* Sends the agent has TAKEN (delivered, echo record still in - flight): full-colour ◆ rows rendered ABOVE the live activity - lines — the running turn is the response to THIS message, so its - stream belongs below it. Rendering them after the live lines - made the Generating line flash in above the message for the echo - gap. */} - {atBottom && - inFlightSends - .filter((q) => q.state === 'accepted') - .map((q) => ( - - - - - - {q.text} - - - ))} - {/* The live tool-call status, attached to the burst it belongs to: hugs - the collapsed "Ran N …" fold (or the expanded ● call) above it, and - disappears into the fold's count once the result lands. Live lines - only render while the viewport follows the bottom. */} - {atBottom && runningTool && ( - - - - - - {runningToolLabel}… - - - ({humanDuration(toolElapsed)}) - - - )} - {/* The in-progress assistant response, streamed token-by-token from delta - frames, with its live status hugging beneath; replaced by the - committed step when it lands. */} - {/* Indented to the same 2-column gutter and wrapped in the same 80% - column as the committed assistant item it becomes, so the text - doesn't jump when it lands. */} - {atBottom && liveText && ( - - - - {liveText} - - - )} - {atBottom && generating && ( - - - - - - Generating… - - - ({generatingBits}) - - - )} - {/* Your not-yet-taken sends, at the chat's bottom edge the moment - you hit enter: dim ◆ rows with their pipeline state in the - right-hand metadata column — (sending…) while the POST is in - flight, (queued…) once the server accepts. The moment the agent - takes the message it turns full colour and moves ABOVE the live - lines (the accepted rows before the tool/generating status), - holding that spot through the echo gap (which spans a whole - sandbox wake) until the agent's own user-echo transcript item - replaces it. */} - {atBottom && - inFlightSends - .filter((q) => q.state !== 'accepted') - .map((q) => ( - - - - ◆ - - - - {q.text} - - - {q.state === 'sending' ? '(sending…)' : '(queued…)'} - - - ))} - {/* The fallback live line: a turn is actually in flight (or a send - is on its way to starting one) but nothing else says so — no - tokens streaming, no tool pending, no infra startup block - ticking. This is the harness-boot dead air (a fresh execution - takes ~15-20s to start Claude Code before its first event) and a - running turn's between-records lull; without it a send looks - like the app hung. Gated on the turn, not the session status: a - bare interactive session reads 'working' while it just sits - waiting for your first message, and narrating that would claim - work that isn't happening. */} - {atBottom && - working && - !generating && - !runningTool && - !infraActivity && - (awaitingAgent !== null || sendPending) && ( - - - - - - - {awaitingAgent === 'boot' ? 'Starting the agent' : 'Working'}… - - - - ({humanDuration(elapsed)}) - - + {allRows.slice(view.start, view.end).map((row) => ( + + ))} + {view.showBelow && ( + + {` ↓ ${view.hiddenBelow} more line${view.hiddenBelow === 1 ? '' : 's'} below`} + )} {/* flexShrink=0: when a mis-estimated transcript slice overflows the fixed pane, the squeeze lands on the (overflow-hidden) viewport above, never on the composer/meta rows. */} - {notice && · {notice}} - {/* The composer: a 3-row 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 (a 5-row panel around a 3-row interior). */} + {/* The notice is budgeted at its wrapped height (noticeRows) and + pinned to it, so an unbounded one (a stream error, an API error + detail) can't grow the frame past the pane. */} + {notice && noticeRows > 0 && ( + + · {notice} + + )} + {/* 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. */} {composerVisible && ( = COMPOSER_INTERIOR_ROWS + 2 ? 1 : 0} paddingX={COMPOSER_PAD_X} > {/* One parent Text so a multi-line input flows as a single block @@ -1498,105 +1299,125 @@ export function cursorLineDown(text: string, cursor: number): number | null { // leading escape of the first report is already stripped by ink). const MOUSE_SEQ_RE = /^(?:\u001B?\[<\d+;\d+;\d+[Mm])+$/ -// The viewport slice over a list of entry heights: which contiguous run of -// entries fits in `budget` rows, anchored to the bottom (follow newest), to -// a top entry (scrolled), or with an entry pinned to the bottom edge (the -// ↓-snap when the highlight walks below the frame). Always includes at least -// the anchor entry, even when it alone overflows the budget. Pure, for tests. -export function viewportSlice( - heights: readonly number[], - budget: number, - anchor: { type: 'bottom' } | { type: 'top'; index: number } | { type: 'end'; index: number }, -): { start: number; end: number } { - const n = heights.length - if (n === 0) return { start: 0, end: 0 } - if (anchor.type === 'top') { - const start = Math.max(0, Math.min(anchor.index, n - 1)) - let used = 0 - let end = start - while (end < n) { - if (used + heights[end] > budget && end > start) break - used += heights[end] - end++ - } - return { start, end } - } - const endIdx = anchor.type === 'bottom' ? n - 1 : Math.max(0, Math.min(anchor.index, n - 1)) - let used = 0 - let start = endIdx + 1 - while (start > 0) { - if (used + heights[start - 1] > budget && start <= endIdx) break - used += heights[start - 1] - start-- +// The startup block as screen rows: the "Connected" opener, then the +// session-first hierarchy on its message panel — +// ✻ Session starting… +// ✻ Sandbox starting… +// ✓ Preparing image · incremental build · 3.4s +// ✻ Running setup… +// While in progress the whole hierarchy shows, the live level ticking with its +// log tail. Once ready it collapses to the bare headline; highlighting it (↑ +// from the composer) and pressing → reveals the config + sandbox lines, → +// again opens the phase panel, →/← on a phase shows/hides its logs. Its rows +// sit at the top of the same flat list as everything else, so it scrolls out +// of frame like any other content. +function sandboxRows(o: { + sandbox: SandboxState | null + infraActivity: string | null + settled: boolean + expanded: boolean + cols: number +}): TranscriptRow[] { + const { sandbox, infraActivity, settled, cols } = o + const key = 'sandbox' + const rows: TranscriptRow[] = [] + const width = contentWidth(cols, { panel: true }) + // Every row of the block sits on the panel and reserves the standard gutter, + // so the ▶ marker lands in the headline's mark slot when the block is + // highlighted — the same treatment every other entry gets. + const line = (spans: RowSpan[], extra: Partial = {}): void => { + rows.push({ id: `${key}:r${rows.length}`, entryKey: key, panel: true, spans, ...extra }) } - return { start, end: endIdx + 1 } -} + // The conversation's opening line: where it lives. Plain text — an OSC 8 + // hyperlink here gets broken by ink's wrapping and swallows the label; the + // clickable dashboard link lives in the footer meta line. It introduces the + // block rather than being part of the startup story, so it keeps its own ✦ + // and never takes the selection marker — the headline below does. + rows.push({ + id: `${key}:hdr`, + entryKey: key, + spans: [{ text: '✦ Connected to ellipsis.dev', bold: true }], + }) + const ready = (sandbox?.done ?? false) && !infraActivity + // A live status word overrides a stale done-headline: on a wake the status + // flips before the new session_starting record lands, and "Session ready!" + // must not linger. + const headline = ready + ? (sandbox?.headline ?? '') + : `${(!sandbox || sandbox.done ? (infraActivity ?? 'Session starting') : sandbox.headline).replace(/…$/, '')}…` + line( + [ + // The settled headline ("Session ready!") reads bold in the default + // foreground over the dim trace beneath it; while starting it stays dim + // like the rest of the block. + { text: fit(headline, width - 18), dim: !ready, bold: ready }, + ], + { + gutter: { + text: ready ? '✓' : LIVE_GLYPH, + color: ready ? theme.success : theme.foreground, + }, + // While starting, the headline pulses and carries the elapsed clock. + ...(ready ? {} : { tick: 'elapsed' as const, pulse: true }), + }, + ) -// Agent and user prose rendered as markdown (bold, headings, bullets, tables, -// fenced code), pre-wrapped to the column it will occupy. Only these two kinds -// go through it: tool lines and system notices are the SDK's own formatting, -// where a stray asterisk or pipe is literal text. Items without markdown come -// back untouched, so the common case allocates nothing. Pure, for tests. -export function withRenderedMarkdown(item: TranscriptItem, width: number): TranscriptItem { - if (item.kind !== 'assistant' && item.kind !== 'user') return item - if (!hasMarkdown(item.text)) return item - const rendered = renderMarkdown(item.text, width) - return rendered === item.text ? item : { ...item, text: rendered } + // The log, ONE level under the headline: no phase tree, no per-phase tails, + // no drilling. While the session is coming up you see the last + // SANDBOX_LOG_ROWS lines of everything that has happened — including build + // and setup output, which is the whole point of showing it — headed by a + // count of what scrolled past. Once it settles the block collapses to the + // bare headline, and → re-opens the same log to re-read it. + if (!sandbox) return rows + const show = settled && !o.expanded ? [] : lastLines(sandbox.log, SANDBOX_LOG_ROWS) + const hidden = sandbox.log.length - show.length + if (show.length > 0 && hidden > 0) { + line([ + { text: ' ' }, + { text: `… +${hidden} earlier line${hidden === 1 ? '' : 's'}`, dim: true }, + ]) + } + for (const entry of show) { + // A milestone still open pulses; output lines and closed milestones are + // plain dim trace. Marks stay in one column, so the log reads as a list. + const live = entry.kind === 'step' && !sandbox.sandboxDone + const mark: RowSpan = + entry.kind === 'failed' + ? { text: '✗', color: theme.error } + : entry.kind === 'output' + ? { text: ' ' } + : live + ? { text: LIVE_GLYPH, color: theme.foreground, pulse: true } + : { text: '✓', color: theme.success }; + line( + [ + { text: ' ' }, + mark, + { text: ' ' }, + { + text: fit(entry.text, width - 5), + color: entry.kind === 'failed' ? theme.error : undefined, + dim: entry.kind !== 'failed', + }, + ], + live ? { pulse: true } : {}, + ) + } + return rows } -// Estimated rows a transcript item occupies on screen: its (possibly -// clamped) body lines, wrapped at the given width, plus the "+N lines" -// marker and the blank spacer row. Widths are VISIBLE columns — markdown -// rendering leaves ANSI escapes in the text, which occupy none. Pure, for +// The tail of the startup log: the last `max` lines, which is what you want +// while a session comes up — the newest output, not the oldest. Pure, for // tests. -export function estimateItemRows(item: TranscriptItem, width: number, clamp: boolean): number { - const clamped = - clamp && isCollapsible(item) - ? clampLines(item.text, COLLAPSE_LINES) - : { body: item.text, more: 0 } - let rows = (item.spaceBefore ? 1 : 0) + (clamped.more > 0 ? 1 : 0) - for (const line of clamped.body.split('\n')) { - rows += Math.max(1, Math.ceil(visibleWidth(line) / width)) - } - return rows +export function lastLines(log: readonly SandboxLogLine[], max: number): SandboxLogLine[] { + return log.length <= max ? [...log] : log.slice(log.length - max) } -// Estimated rows of the startup block in its current shape: the headline, -// plus — while starting or drilled into — the config line, the sandbox line, -// one row per phase, and the selected phase's open log lines. Settled and -// not drilled into, the block is just the bare headline. -function sandboxBlockRows( - sandbox: SandboxState | null, - settled: boolean, - details: boolean, - open: boolean, - logsOpen: boolean, - stepCursor: number, -): number { - // The "Connected to ellipsis.dev" opener + its blank row, then the headline - // inside its message panel (MESSAGE_PAD rows above and below the content). - let rows = 3 + MESSAGE_PAD * 2 - const expanded = - sandbox != null && - (sandbox.configName != null || sandbox.sandboxLine != null) && - (!settled || details) - if (!expanded) return rows - if (sandbox.configName != null) rows += 1 - if (sandbox.sandboxLine != null) rows += 1 - const steps = !settled || open ? sandbox.steps : [] - rows += steps.length - const cursor = Math.min(stepCursor, Math.max(0, steps.length - 1)) - for (const [i, step] of steps.entries()) { - const running = step.status === 'running' && !sandbox.sandboxDone - // A running step always shows its live tail; a finished step's logs - // show only while selected in the open panel with logs toggled on. - // Shown lines plus the "+N earlier lines" heading when some are elided. - if (running || (open && logsOpen && i === cursor)) { - const shown = Math.min(step.lines.length, running ? RUNNING_TAIL_LINES : FINISHED_LOG_LINES) - rows += shown + (step.lines.length > shown ? 1 : 0) - } - } - return rows +// A single line, truncated to `width` visible columns — the startup block's +// lines are structural (indent + mark + label), so an over-long one is cut +// rather than reflowed onto a row the layout didn't account for. +function fit(text: string, width: number): string { + return fitLines(text, Math.max(4, width))[0] ?? '' } // The run of tool/tool_result items a collapsed fold stands for. A fold's key @@ -1636,54 +1457,48 @@ export function hookPhrase(step: string): string { } } -// A running step's live log tail height, and how much of a finished step's -// log the panel shows before eliding the head with a "+N earlier lines" row. -const RUNNING_TAIL_LINES = 5 -const FINISHED_LOG_LINES = 100 - -export type SandboxStepStatus = 'running' | 'done' | 'failed' -export type SandboxStep = { +// Lines of the startup log the block shows: the last ten, which is enough to +// watch an image build or a setup hook make progress without the block taking +// over the chat window. Anything older is counted in the "… +N earlier lines" +// head above them. +const SANDBOX_LOG_ROWS = 10 + +// One line of the startup log: a milestone (a phase opening or closing, the +// config resolving, the box coming up) or a line of output from whatever the +// sandbox was running. They all live in ONE flat list in feed order, because +// that is how they happened and how you read them. +export type SandboxLogKind = 'step' | 'output' | 'done' | 'failed' +export type SandboxLogLine = { key: string - label: string - status: SandboxStepStatus - // "cached image · 1.2s" — the completed/failed transition's cache-tier - // detail and duration, for the step's closing summary. - note: string | null - lines: string[] - // Created from output chunks alone (a feed recorded before sandbox_phase - // transitions existed) — such steps close on the next step, not on a - // transition. - inferred: boolean - // Rendered one level under its bare-phase sibling: the key is phase:step - // AND an entry keyed exactly `phase` exists (the image phase's build/ - // container/smoke children under "Preparing image"). Hook steps have no - // bare-phase sibling and stay flat. - child: boolean + kind: SandboxLogKind + text: string } -// The startup story as a THREE-LEVEL hierarchy, session-first: the headline -// is the SESSION's state ("Session scheduled…" → "Session starting…" → -// "Session ready!"), the sandbox is one child line under it, and the -// provisioning phases are children of the sandbox. `done` stops the -// headline's ticking timer; the hierarchy stays on screen as the all-✓ -// trace, its logs hidden behind → in the panel. + +// The startup story as a HEADLINE plus a FLAT LOG. +// +// It used to be a three-level tree (session → sandbox → phases → each phase's +// own log tail), drilled into with →. That shape hid the thing you actually +// want when a session is slow to come up — the build output — three keystrokes +// deep, and it split one chronological story across separate per-phase tails. +// Now every milestone and every line of build/setup output goes into one +// ordered list, and the block shows the LAST few (SANDBOX_LOG_ROWS) of it. export type SandboxState = { // The current top-level line ("Session scheduled…", "Session starting…", // "Waking the session…", "Retrying…", "Session ready!"). headline: string done: boolean - // The agent config resolved at scheduling, shown as its own child line - // under the headline (NOT in the headline, which the next lifecycle - // record replaces — a config baked in there flashes and vanishes). + // Whether the sandbox itself has finished provisioning, so the log's live + // lines stop pulsing. + sandboxDone: boolean + // The agent config resolved at scheduling, held apart from the log because it + // outlives a restart: the log drops on a retry/wake, but which config the + // session runs is still true. Rendered as the log's first line. configName: string | null // The commit of the config file in the repo it's owned at (the sync // provenance), when the backend sends it. Shortened for display. configCommitSha: string | null - // Level 2: the sandbox child line ("Sandbox starting…" or the - // "Sandbox ready · cached image · 29s" summary), null before provisioning. - sandboxLine: string | null - sandboxDone: boolean - // Level 3: the provisioning phases under the sandbox line. - steps: SandboxStep[] + // Everything that happened during this start, oldest first. + log: SandboxLogLine[] } // The structural slice of a session record the derivations need (the SDK's @@ -1697,19 +1512,36 @@ type LifecycleRecordLike = { session_message_id?: string | null } -// The committed transcript items, with each turn's closing `result` summary -// dropped: its duration and cost are session bookkeeping, not conversation — -// the footer's running spend is where that story lives. An error summary -// survives as its own (red) line under a plain label: a failed turn is -// content. Pure, for tests. +// The chat is a LOG of the session: what was said, and what happened to the +// session while it was being said. So the milestones — it went to sleep, it is +// waking again, it was cancelled — land in the transcript, in feed order, +// alongside the conversation (see SESSION_LOG_RECORDS). Without them a session +// that naps between turns leaves an unexplained gap, and the only account of +// the wake is the startup block up top silently rewriting itself. +// +// Each turn's closing `result` summary is dropped: its duration and cost are +// bookkeeping, not conversation — the footer's running spend is where that +// story lives. An error summary survives as its own (red) line under a plain +// label: a failed turn is content. Pure, for tests. export function reshapeTranscript( records: readonly LifecycleRecordLike[], minRenderFeedSeq: number, ): { items: TranscriptItem[] } { const items: TranscriptItem[] = [] for (const r of records) { - if (r.source === 'lifecycle') continue if (r.feed_seq <= minRenderFeedSeq) continue + if (r.source === 'lifecycle') { + const text = sessionLogText(r) + if (text) { + items.push({ + key: `s${r.feed_seq}`, + kind: 'notice', + text, + spaceBefore: true, + }) + } + continue + } const isResult = r.source === 'claude_code' && r.payload.type === 'result' // recordToItems reads only the structural slice (source, record_type, // payload); its SessionRecordWire param type isn't exported from the @@ -1728,6 +1560,47 @@ export function reshapeTranscript( return { items } } +// The session milestones worth a line in the chat log, and how each reads. +// Deliberately a SHORT list of state changes a reader would otherwise be left +// guessing about: +// - the session parked between turns, and what wakes it +// - it is coming back up (a wake, or an infra retry after a wobble) +// - it came back and the conversation continues +// - it was stopped or cancelled +// Everything else the lifecycle feed carries is startup detail (sandbox phases, +// setup log chunks, per-phase timings) and belongs to the startup block up top, +// not the conversation — logging it would bury the chat in provisioning noise. +// +// `session_ready`-style milestones are deliberately absent for a FIRST start: +// the startup block already tells that story in place. A wake is different — +// it happens long after the block settled, mid-conversation. Pure, for tests. +export function sessionLogText(record: LifecycleRecordLike): string | null { + const p = record.payload + switch (record.record_type) { + case 'session_idle': + return 'Session asleep — your next message wakes it' + case 'session_starting': { + // Only a WAKE is logged: the first start is the startup block's story. + const wake = typeof p.wake_index === 'number' ? p.wake_index : 0 + const attempt = typeof p.attempt === 'number' ? p.attempt : 0 + if (attempt > 0) return 'Restarting the sandbox after a transient error…' + return wake > 0 ? 'Waking the session…' : null + } + case 'session_retrying': + return typeof p.reason === 'string' && p.reason + ? `Retrying · ${p.reason}` + : 'Retrying after a transient error…' + case 'session_resumed': + return 'Session awake — picking up where it left off' + case 'session_cancelled': { + const reason = typeof p.reason === 'string' && p.reason ? ` · ${p.reason}` : '' + return `Session cancelled${reason}` + } + default: + return null + } +} + // Whether a turn is IN FLIGHT (a turn_started record without its // turn_completed/turn_failed), and which silence it is: 'boot' when the // harness has emitted NOTHING this execution — Claude Code is still starting @@ -1856,17 +1729,17 @@ function stepLabel(phase: string, step: string | null): string { return sandboxPhaseLabel(phase) } -// The startup story from the lifecycle records of the LATEST start, as the -// session-first hierarchy: the headline tracks the session-subject records -// ("Session scheduled…" → "Session starting…"/"Waking…"/"Retrying…" → -// "Session ready!" when the sandbox comes up), the sandbox is one child line, -// and the provisioning phases are its children — opened by their -// sandbox_phase `started` transition, closed (with cache-tier/duration note) -// by `completed`/`failed` — with sandbox_output chunks attaching their lines -// to the matching step (exact phase:step, then the bare phase, then an -// inferred step for feeds that predate phase transitions). session_starting -// begins a fresh story (a wake or infra retry drops the previous one). -// null when no lifecycle record has been seen. Pure, for tests. +// The startup story from the lifecycle records of the LATEST start: a headline +// tracking the session's own state ("Session scheduled…" → "Session starting…" +// / "Waking…" / "Retrying…" → "Session ready!"), plus ONE FLAT LOG of +// everything that happened on the way up, in feed order — the config +// resolving, each provisioning phase opening and closing (with its cache tier +// and duration), and every line of output those phases produced (image builds, +// clones, setup hooks). +// +// session_starting begins a fresh story: a wake or an infra retry drops the +// previous start's log rather than appending to it. null when no lifecycle +// record has been seen. Pure, for tests. export function deriveSandboxState( records: readonly LifecycleRecordLike[], minFeedSeq: number, @@ -1874,302 +1747,251 @@ export function deriveSandboxState( let seen = false let headline = 'Session starting…' let done = false + let sandboxDone = false let configName: string | null = null let configCommitSha: string | null = null - let sandboxLine: string | null = null - let sandboxDone = false - let steps: SandboxStep[] = [] + let log: SandboxLogLine[] = [] + // Phases still open, so a `completed`/`failed` transition can close the line + // it opened rather than adding a second one. + let open = new Map() + const push = (record: LifecycleRecordLike, kind: SandboxLogKind, text: string): SandboxLogLine => { + const entry = { key: `${record.feed_seq}:${log.length}`, kind, text } + log.push(entry) + return entry + } + const reset = (): void => { + log = [] + open = new Map() + sandboxDone = false + } + for (const record of records) { if (record.feed_seq <= minFeedSeq || record.source !== 'lifecycle') continue const p = record.payload - if (record.record_type === 'session_scheduled') { - seen = true - headline = 'Session scheduled…' - configName = typeof p.config_name === 'string' && p.config_name ? p.config_name : null - configCommitSha = - typeof p.config_commit_sha === 'string' && p.config_commit_sha - ? p.config_commit_sha - : null - done = false - } else if ( - record.record_type === 'session_starting' || - record.record_type === 'session_retrying' - ) { - seen = true - // Every claim starts a fresh story: the headline takes over ("Session - // starting…", "Waking the session…", "Retrying…") and the previous - // start's sandbox children drop. - headline = lifecycleText(record.record_type, p) ?? 'Session starting…' - done = false - sandboxLine = null - sandboxDone = false - steps = [] - } else if (record.record_type === 'session_resumed') { - seen = true - // The wake mounted its snapshots and the conversation continues — the - // session-level outcome, same beat as ready on a fresh start. - headline = 'Session ready!' - done = true - } else if (record.record_type === 'session_idle') { - seen = true - headline = 'Session idle — your next message wakes it' - done = true - } else if (record.record_type === 'sandbox_starting') { - seen = true - sandboxLine = 'Sandbox starting…' - sandboxDone = false - steps = [] - } else if (record.record_type === 'sandbox_phase') { - seen = true - const phase = typeof p.phase === 'string' && p.phase ? p.phase : 'setup' - const step = typeof p.step === 'string' && p.step ? p.step : null - const key = step ? `${phase}:${step}` : phase - let entry = steps.find((s) => s.key === key) - if (!entry) { - entry = { - key, - label: stepLabel(phase, step), - status: 'running', - note: null, - lines: [], - inferred: false, - child: false, - } - steps.push(entry) + switch (record.record_type) { + case 'session_scheduled': { + seen = true + headline = 'Session scheduled…' + done = false + configName = typeof p.config_name === 'string' && p.config_name ? p.config_name : null + configCommitSha = + typeof p.config_commit_sha === 'string' && p.config_commit_sha + ? p.config_commit_sha + : null + break + } + case 'session_starting': + case 'session_retrying': { + seen = true + // Every claim starts a fresh story: the headline takes over and the + // previous start's log drops. + headline = lifecycleText(record.record_type, p) ?? 'Session starting…' + done = false + reset() + break + } + case 'session_resumed': { + seen = true + // The wake mounted its snapshots and the conversation continues — the + // session-level outcome, same beat as ready on a fresh start. + headline = 'Session ready!' + done = true + break } - entry.inferred = false - if (p.status === 'completed' || p.status === 'failed') { - entry.status = p.status === 'completed' ? 'done' : 'failed' - const detail = - p.detail && typeof p.detail === 'object' - ? (p.detail as Record) - : {} - // "full build (2s)", "(42s)", or a bare tier — the duration always - // parenthesized (the app-wide duration format). - const tier = cacheTierLabel(detail.cache_tier) - const dur = msLabel(p.duration_ms) - const bits = [...(tier ? [tier] : []), ...(dur ? [`(${dur})`] : [])] - entry.note = bits.length ? bits.join(' ') : null + case 'session_idle': { + seen = true + headline = 'Session idle — your next message wakes it' + done = true + break } - } else if (record.record_type === 'sandbox_output') { - seen = true - const phase = typeof p.phase === 'string' && p.phase ? p.phase : 'setup' - const step = typeof p.step === 'string' && p.step ? p.step : null - const outputKey = sandboxOutputStep(p) - let entry = - (step ? steps.find((s) => s.key === `${phase}:${step}`) : undefined) ?? - steps.find((s) => s.key === phase) ?? - steps.find((s) => s.key === outputKey) - if (!entry) { - // No transition opened a home for this output: an inferred step (old - // feeds). A new inferred step means the previous inferred one ended. - for (const s of steps) if (s.inferred && s.status === 'running') s.status = 'done' - entry = { - key: outputKey, - label: hookPhrase(outputKey), - status: 'running', - note: null, - lines: [], - inferred: true, - child: false, + case 'sandbox_starting': { + seen = true + reset() + push(record, 'step', 'Starting sandbox…') + break + } + case 'sandbox_phase': { + seen = true + const phase = typeof p.phase === 'string' && p.phase ? p.phase : 'setup' + const step = typeof p.step === 'string' && p.step ? p.step : null + const key = step ? `${phase}:${step}` : phase + const label = stepLabel(phase, step) + if (p.status === 'completed' || p.status === 'failed') { + const detail = + p.detail && typeof p.detail === 'object' ? (p.detail as Record) : {} + // "full build (2s)", "(42s)", or a bare tier — the duration always + // parenthesized (the app-wide duration format). + const tier = cacheTierLabel(detail.cache_tier) + const dur = msLabel(p.duration_ms) + const note = [...(tier ? [tier] : []), ...(dur ? [`(${dur})`] : [])].join(' ') + const failed = p.status === 'failed' + const base = failed ? `${label} failed` : label + const text = note ? (note.startsWith('(') ? `${base} ${note}` : `${base} · ${note}`) : base + const line = open.get(key) + if (line) { + // Close the line this phase opened, in place: one line per phase, + // not an opening line and a closing one. + line.kind = failed ? 'failed' : 'done' + line.text = text + open.delete(key) + } else { + push(record, failed ? 'failed' : 'done', text) + } + } else if (!open.has(key)) { + open.set(key, push(record, 'step', `${label}…`)) } - steps.push(entry) + break } - entry.lines.push(...sandboxOutputLines(p)) - } else if (record.record_type === 'sandbox_ready') { - seen = true - for (const s of steps) if (s.status === 'running') s.status = 'done' - const timings = - p.phase_timings && typeof p.phase_timings === 'object' - ? Object.values(p.phase_timings as Record) - : [] - const totalSeconds = timings.reduce( - (acc, v) => (typeof v === 'number' && isFinite(v) ? acc + v : acc), - 0, - ) - const tier = cacheTierLabel(p.cache_tier) - sandboxLine = - ['Sandbox ready', ...(tier ? [tier] : [])].join(' · ') + - (totalSeconds > 0 ? ` (${humanDuration(totalSeconds)})` : '') - sandboxDone = true - // The box coming up is the session-level outcome too: the headline - // settles on ✓ over the all-done step trace. - headline = 'Session ready!' - done = true - } - } - // Nest a phase:step entry one level under its bare-phase sibling, when - // one exists (the image phase opens "Preparing image" then its build/ - // container/smoke steps). Keyed generically on the phase prefix, so any - // phase that gains steps nests the same way. - for (const s of steps) { - const colon = s.key.indexOf(':') - s.child = colon > 0 && steps.some((o) => o.key === s.key.slice(0, colon)) - } - return seen - ? { headline, done, configName, configCommitSha, sandboxLine, sandboxDone, steps } - : null -} - -// One timeline step as its collapsed display line: a running step shows its -// label (its live log tail renders as dim lines BENEATH it, not inline), a -// finished one its closing note (cache tier, parenthesized duration), a -// failed one says so. A note that leads with its "(duration)" attaches with -// a space ("Building image (42s)"); a tier-led note takes the dot separator -// ("Preparing image · full build (2s)"). Pure, for tests. -export function sandboxStepLine(step: SandboxStep): string { - if (step.status === 'running') { - return `${step.label}…` - } - const base = step.status === 'failed' ? `${step.label} failed` : step.label - if (!step.note) return base - return step.note.startsWith('(') ? `${base} ${step.note}` : `${base} · ${step.note}` -} - -// Long bodies collapse to this many lines until ctrl+r expands them. -const COLLAPSE_LINES = 6 - -// Which items collapse when long: tool results and user turns (the latter carry -// the re-injected run context, which is bulky). Assistant prose stays full. -function isCollapsible(item: TranscriptItem): boolean { - return ( - (item.kind === 'tool_result' || item.kind === 'user') && - item.text.split('\n').length > COLLAPSE_LINES - ) -} - -// The sender icon in the 2-column gutter: ◆ (cyan) marks a message you sent -// (the --prompt initial message included — it's a user message), ● marks the -// assistant's prose (default foreground; the tool-call ● is green + bold, so -// the two never read the same), ✦ (dim) marks system/notice lines — the -// infrastructure speaking. Everything else keeps the SDK's glyph (⎿ results, -// ✻ thinking) or none. The › selection highlight replaces the icon in the -// same slot, so a selected line always reads differently from its resting -// state. Pure, for tests. -export function gutterFor(item: TranscriptItem): string { - if (item.kind === 'user') return '◆' - if (item.kind === 'assistant') return '●' - if (item.kind === 'system' || item.kind === 'notice') return '✦' - return item.gutter ?? '' -} - -// Colour + weight for each transcript item kind, matched loosely to Claude Code. -function styleFor(item: TranscriptItem): { - gutterColor?: string - textColor?: string - dim: boolean - bold: boolean -} { - const kind: ItemKind = item.kind - switch (kind) { - case 'tool': - return { gutterColor: theme.success, bold: true, dim: false } - case 'tool_result': - return { - textColor: item.isError ? theme.error : undefined, - dim: !item.isError, - bold: false, + case 'sandbox_output': { + seen = true + for (const l of sandboxOutputLines(p)) push(record, 'output', l) + break } - // User copy stays white like the assistant's (the ◆ icon marks the - // sender); cyan text always and only means "the selection is here". - case 'user': - return { gutterColor: theme.foreground, bold: true, dim: false } - case 'error': - return { gutterColor: theme.error, textColor: theme.error, dim: false, bold: false } - case 'summary': - return { - textColor: item.isError ? theme.error : undefined, - dim: true, - bold: false, + case 'sandbox_ready': { + seen = true + // Anything still open finished when the box came up. + for (const [, line] of open) line.kind = 'done' + open = new Map() + const timings = + p.phase_timings && typeof p.phase_timings === 'object' + ? Object.values(p.phase_timings as Record) + : [] + const totalSeconds = timings.reduce( + (acc, v) => (typeof v === 'number' && isFinite(v) ? acc + v : acc), + 0, + ) + const tier = cacheTierLabel(p.cache_tier) + push( + record, + 'done', + ['Sandbox ready', ...(tier ? [tier] : [])].join(' · ') + + (totalSeconds > 0 ? ` (${humanDuration(totalSeconds)})` : ''), + ) + sandboxDone = true + // The box coming up is the session-level outcome too. + headline = 'Session ready!' + done = true + break } - case 'thinking': - case 'system': - case 'notice': - return { dim: true, bold: false } - case 'assistant': - default: - return { dim: false, bold: false } + default: + break + } } + // The config line heads the log: it is the first thing that was decided, and + // it survives the restarts that clear everything below it. + const full: SandboxLogLine[] = configName + ? [ + { + key: 'config', + kind: 'done', + text: `Using ${configName}${configCommitSha ? ` @ ${configCommitSha.slice(0, 7)}` : ''}`, + }, + ...log, + ] + : log + return seen ? { headline, done, sandboxDone, configName, configCommitSha, log: full } : null } -const TranscriptLine = React.memo(function TranscriptLine({ - item, - expanded, - opened, +// One screen row. Exactly one terminal line by construction: the text was +// pre-fitted to the pane (see transcriptRows), and wrap="truncate" is the +// belt-and-braces guarantee — a row that wrapped would push every row below it +// down and slide the window out of sync with the scroll position. +// +// The selected row steps onto the lighter active surface, the app-wide "you are +// here" treatment (the focused composer, sidebar rows, dropdown options all +// match). Never inverse: a bone-white bar is far too loud on the charcoal +// canvas. +const RowLine = React.memo(function RowLine({ + row, + cols, selected, - indent = false, + seconds, + pulseOn, }: { - item: TranscriptItem - expanded: boolean - // This line was opened in place with → while highlighted (un-clamps it). - opened: boolean - // This line is the transcript-navigation highlight: cyan selection glyph - // in the gutter, cyan text. + row: TranscriptRow + cols: number selected: boolean - // This line is an opened fold's child ("Ran 2 …" → its tool calls): the - // whole row shifts one level (2 columns) right, so the expansion reads as - // the fold's children in the chat hierarchy. - indent?: boolean + // The row's ticking duration, resolved here so the once-a-second tick + // repaints this line instead of rebuilding the transcript's rows. + seconds: number + // The shared pulse phase (see PULSE_MS). Only a pulsing row reads it, so + // the blink repaints the live lines and leaves the rest of the window alone. + pulseOn: boolean }): React.ReactElement { - const mt = item.spaceBefore ? 1 : 0 - const { gutterColor, textColor, dim, bold } = styleFor(item) - - // Every line (assistant prose included) reserves the same 2-column gutter, - // so the › highlight fills the slot IN PLACE of whatever glyph lives there - // and the text never shifts when the selection lands on it. - - // Long tool results and user turns collapse to a compact body with a - // "+N lines" marker unless ctrl+r has expanded the transcript or → opened - // this line. - const clamped = - !expanded && !opened && isCollapsible(item) - ? clampLines(item.text, COLLAPSE_LINES) - : { body: item.text, more: 0 } - - // Messages (user + assistant prose) sit on the same lifted panel the - // composer uses, full width like the input box, with a 1-cell pad inside - // the tint on all four sides (MESSAGE_PAD — the viewport height estimates - // count the two pad rows); tool chatter and notices stay on the canvas. - // The selected line — any kind — steps onto the lighter active surface, - // the app-wide "you are here" treatment (the focused composer, sidebar - // rows, dropdown options all match). Never inverse: a bone-white bar is - // far too loud on the charcoal canvas. - const isMessage = item.kind === 'user' || item.kind === 'assistant' + const background = selected || row.activeRow + ? SURFACE_ACTIVE + : row.panel + ? SURFACE_ELEVATED + : undefined + // The "+N lines" marker's hint names the key that actually opens it: → when + // the line is highlighted, ctrl+r otherwise. + const spans: RowSpan[] = row.clampedLines + ? [ + { + text: `… +${row.clampedLines} lines (${selected ? '→' : 'ctrl+r'} to expand)`, + dim: true, + }, + ] + : row.spans + // A pulsing mark's off beat SWAPS ITS COLOUR rather than setting ink's + // dimColor: dim is \x1b[2m, which a fair number of terminals drop entirely + // when a 24-bit foreground is also set — the blink would silently do nothing + // there. Bone → grey is a real colour change, so it reads everywhere. + const markColor = (span: RowSpan): string | undefined => + span.pulse && !pulseOn ? theme.muted : span.color + // Durations always render parenthesized, in the right-hand metadata column. + const right = row.tick + ? { text: `(${[humanDuration(seconds), row.right?.text].filter(Boolean).join(' ')})`, dim: true } + : row.right + // height=1 is load-bearing: a blank row (a spacer, or a message panel's pad) + // has no text, and ink collapses an empty Box to zero height — the row would + // silently vanish, leaving the window short of the rows the scroll math + // counted. A tinted row also paints its background across the FULL pane + // width, so the panel reads as a block, not a ragged strip behind the text. return ( - - + + {row.panel && } + {row.indent ? : null} + + {/* The gutter glyph, or the selection marker in its place — same + 1-char slot, so the text never shifts when the highlight lands. + A live row's mark pulses by DIMMING on the off beat: the glyph + itself never changes, so the column holds still and the eye reads + a heartbeat rather than a character swapping in and out. */} - {selected ? SELECTION_GLYPH : gutterFor(item)} + {selected && row.gutter ? SELECTION_GLYPH : (row.gutter?.text ?? '')} - - - {clamped.body} - {item.detail ? ( - - {item.detail} + + + {spans.map((span, i) => ( + + {span.text} - ) : null} + ))} - {clamped.more > 0 && ( - - … +{clamped.more} lines ({selected ? '→' : 'ctrl+r'} to expand) - - )} + {right && ( + + + {right.text} + + + )} + {row.panel && } ) }) diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts new file mode 100644 index 0000000..50e375e --- /dev/null +++ b/src/ui/transcriptRows.ts @@ -0,0 +1,518 @@ +import { clampLines, type ItemKind, type TranscriptItem } from '@ellipsis-dev/sdk/store' +import { fitLines, hasMarkdown, renderMarkdown, visibleWidth } from '../lib/markdown' +import { theme } from '../lib/theme' + +// The transcript as a flat list of SCREEN ROWS — the unit the chat window +// scrolls by. Everything in the window (the startup block, messages, tool +// chatter, in-flight sends, the live activity lines) is flattened to rows +// before it renders, each row exactly one terminal line tall and no wider +// than the pane. +// +// Rows, not entries, because a message can be taller than the window: an +// entry-granular viewport can only show an entry whole or not at all, so a +// long message becomes unreadable — it fills the frame, and one scroll notch +// throws all of it away. Row-granular, the window can sit anywhere inside it. +// +// Rows are also EXACT, which is what lets the window pack itself full: text is +// pre-wrapped here at the width it will occupy (fitLines) and the renderer +// truncates instead of wrapping, so a slice of N rows always paints N lines. +// An estimate-based budget has to leave slack for its own rounding errors, and +// that slack shows up as dead space and phantom "… 1 newer" markers. + +// The 2-column gutter a transcript line reserves for its sender glyph (◆/●/⎿), +// so the selection marker can replace the glyph in place without the text +// shifting, and a wrapped line's continuation aligns under its first. +export const GUTTER_COLS = 2 + +// Horizontal pad inside a chat message's panel — the text sits one cell off +// the tint's edge, like the composer's interior. There is deliberately no +// VERTICAL pad: a blank tinted row above and below every message costs two +// rows of the window each time, and the tint alone already separates the +// message from the canvas around it. The blank separator between blocks +// (spacerRow) is the breathing room. +export const MESSAGE_PAD = 1 + +// Long bodies collapse to this many lines until ctrl+r (or → on the line) +// expands them. +const COLLAPSE_LINES = 6 + +// A run of same-styled text inside a row. Rows carry spans rather than one +// string because a single line mixes styles: a green ✓ before dim prose, a +// bold tool name before its dim "(3 files)" detail. +export type RowSpan = { + text: string + color?: string + dim?: boolean + bold?: boolean + // This span is a LIVE mark: it pulses while the work it describes is in + // flight (see TranscriptRow.pulse and LIVE_GLYPH). + pulse?: boolean +} + +export type TranscriptRow = { + // Unique per row, for React keys. + id: string + // The entry (a transcript item's key, or 'sandbox') this row belongs to: + // what ↑/↓ highlights, and what the scroll anchor holds onto so streamed + // appends and re-wraps can't slide the window. + entryKey: string + // The gutter glyph, set on an entry's FIRST row only — a multi-row item + // shows one sender icon, and its continuation rows align under it. + gutter?: RowSpan + // Blank columns before the gutter: an opened fold's children sit one level + // in, so they read as the fold's children. + indent?: number + spans: RowSpan[] + // 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, with a horizontal pad. + panel?: boolean + // On the active surface regardless of the transcript selection — the + // startup block's selected phase, which has its own cursor. + activeRow?: boolean + // A blank separator row: never tinted, never highlighted, so the gap + // between blocks stays canvas even when the block below it is selected. + 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 + // knows and the row builder deliberately doesn't — otherwise every arrow + // keypress would rebuild the whole transcript. + clampedLines?: number + // A ticking duration, appended to the row's text at render time. Kept out of + // the row's spans so the once-a-second tick repaints one line instead of + // rebuilding (and re-wrapping) the entire transcript. + tick?: 'elapsed' | 'tool' + // This row contains a pulsing mark (its gutter, or one of its spans). Like + // `tick`, the phase is resolved at RENDER time — the row only records that it + // has one — so the blink repaints a glyph instead of rebuilding the + // transcript's rows, and rows without one are memoized past it entirely. + pulse?: boolean +} + +// The mark on a live line — a tool call running, tokens streaming, a sandbox +// coming up. It pulses (see TranscriptRow.pulse), which is the app's one +// "something is happening right now" signal; a settled line takes ✓, ● or ✦ +// instead. Filled, because a pulsing outline reads as flicker rather than a +// heartbeat. +export const LIVE_GLYPH = '⏺' + +// The mark on a line nested under the message that produced it: a tool call +// the agent made while writing that message, and the result that came back. +// It reads as a branch off the prose above, which is what the nesting means. +export const BRANCH_GLYPH = '⎿' + +// Columns a nested line shifts right, so its branch glyph sits under the +// parent's text rather than under the parent's own mark. +export const NEST_INDENT = 2 + +// Printable columns a row's text may occupy in a pane `cols` wide. +export function contentWidth( + cols: number, + opts: { panel?: boolean; indent?: number } = {}, +): number { + const pad = opts.panel ? MESSAGE_PAD * 2 : 0 + return Math.max(8, cols - pad - GUTTER_COLS - (opts.indent ?? 0)) +} + +export function spacerRow(entryKey: string, id: string): TranscriptRow { + return { id, entryKey, spans: [], spacer: true } +} + +// 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. +export function itemRows( + item: TranscriptItem, + cols: number, + opts: { indent?: number; clamp: boolean; nested?: boolean; attach?: boolean }, +): TranscriptRow[] { + const panel = isMessage(item) + const indent = opts.indent ?? 0 + const width = contentWidth(cols, { panel, indent }) + const shown = withRenderedMarkdown(item, width) + const clamped = + opts.clamp && isCollapsible(shown) + ? clampLines(shown.text, COLLAPSE_LINES) + : { body: shown.text, more: 0 } + const { gutterColor, textColor, dim, bold } = styleFor(shown) + // 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 + // ("Ran 2 tool calls") takes the branch glyph — as a notice it would + // otherwise wear ✦, the mark for the infrastructure speaking, which is not + // what a fold is. + const gutter = + opts.nested && shown.kind === 'notice' ? BRANCH_GLYPH : gutterFor(shown) + + const rows: TranscriptRow[] = [] + // `attach` overrides the item's own spacing: a nested line sits directly + // under its parent, with no blank row to detach it. + if (item.spaceBefore && !opts.attach) rows.push(spacerRow(item.key, `${item.key}:sp`)) + let bodyRows = 0 + const push = (spans: RowSpan[], extra: Partial = {}): void => { + rows.push({ + id: `${item.key}:r${rows.length}`, + entryKey: item.key, + gutter: + bodyRows++ === 0 + ? { text: gutter, color: gutterColor, dim: dim && !shown.isError } + : undefined, + indent, + spans, + panel, + ...extra, + }) + } + + const lines = fitLines(clamped.body, width) + for (const [i, line] of lines.entries()) { + // The detail ("(3 files)") trails the body's last line when it fits, and + // takes rows of its own when it doesn't. + const detail = i === lines.length - 1 && shown.detail ? shown.detail : null + if (detail && visibleWidth(line) + visibleWidth(detail) <= width) { + push([{ text: line, color: textColor, dim, bold }, { text: detail, dim: true }]) + } else { + push([{ text: line, color: textColor, dim, bold }]) + if (detail) for (const l of fitLines(detail, width)) push([{ text: l, dim: true }]) + } + } + if (clamped.more > 0) push([], { clampedLines: clamped.more }) + return rows +} + +// How each visible item is placed in the chat: nested under the message that +// produced it, or standing on its own. +// +// A tool call is not a turn in the conversation — it is something the agent did +// while writing the message above it. So a run of tool activity (the ● call, +// its ⎿ result, and any collapsed "Ran N …" fold standing in for them) is +// indented under the preceding assistant message and marked with the branch +// glyph, attached with no blank row between. Prose, user messages and notices +// keep their own gutter mark and their spacing. +// +// A run with no assistant message before it (the agent opened the turn with a +// tool call) still nests — under the user message that prompted it — because +// the indent is what says "this is work, not talk". Only a run at the very top +// of the transcript, with no parent at all, stays flat. Pure, for tests. +export function layOutItems( + items: readonly TranscriptItem[], + opts: { indentedKeys?: ReadonlySet } = {}, +): { item: TranscriptItem; indent: number; nested: boolean; attach: boolean }[] { + const out: { item: TranscriptItem; indent: number; nested: boolean; attach: boolean }[] = [] + // Whether anything at all precedes the current run — a run at the head of + // the transcript has nothing to hang off. + let hasParent = false + for (const item of items) { + if (isToolActivity(item)) { + const nested = hasParent + out.push({ + item, + // A fold opened with → indents its children one level FURTHER, so the + // expansion still reads as the fold's own children. + indent: (nested ? NEST_INDENT : 0) + (opts.indentedKeys?.has(item.key) ? NEST_INDENT : 0), + nested, + // Attach every line of the run: the first to its parent message, the + // rest to the line above. + attach: nested, + }) + continue + } + out.push({ item, indent: 0, nested: false, attach: false }) + hasParent = true + } + return out +} + +// Lines that represent work the agent did rather than something it said: a +// tool call, its result, and the collapsed fold that stands in for a run of +// them (keyed grp:*, kind 'notice'). +export function isToolActivity(item: TranscriptItem): boolean { + return item.kind === 'tool' || item.kind === 'tool_result' || item.key.startsWith('grp:') +} + +// A live status line — "Generating…", "Running Bash(pytest…)…" — with its +// 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( + key: string, + label: string, + tick: 'elapsed' | 'tool', + suffix: string, + cols: number, + hug: boolean, + // The line describes a TOOL CALL in flight, so it nests under the message + // that made the call, exactly where its ⎿ result will land a moment later. + // A "Generating…"/"Working…" line describes the message itself and stays flat. + nested = false, +): TranscriptRow[] { + const indent = nested ? NEST_INDENT : 0 + // Reserve the widest the readout gets ("(1h 3m 30s · ↓ 12.3k tokens)") so + // the label doesn't reflow as the clock ticks. + const width = Math.max(8, contentWidth(cols, { indent }) - visibleWidth(suffix) - 16) + const rows: TranscriptRow[] = hug || nested ? [] : [spacerRow(key, `${key}:sp`)] + rows.push({ + id: `${key}:r`, + entryKey: key, + gutter: { text: LIVE_GLYPH, color: theme.foreground, pulse: true }, + indent, + spans: [{ text: fitLines(label, width)[0] ?? '', dim: true }], + right: { text: suffix, dim: true }, + tick, + pulse: true, + }) + return rows +} + +// An in-flight send, or the streaming assistant response: the same panel a +// committed message sits on, so nothing shifts when the real record lands. +export function pendingMessageRows( + key: string, + text: string, + cols: number, + opts: { gutter: string; dim?: boolean; bold?: boolean; right?: string }, +): TranscriptRow[] { + const width = contentWidth(cols, { panel: true }) + const rows: TranscriptRow[] = [spacerRow(key, `${key}:sp`)] + const lines = fitLines(text, width) + for (const [i, line] of lines.entries()) { + rows.push({ + id: `${key}:r${i}`, + entryKey: key, + gutter: + i === 0 && opts.gutter + ? { text: opts.gutter, color: theme.foreground, dim: opts.dim } + : undefined, + spans: [{ text: line, dim: opts.dim, bold: opts.bold }], + right: i === lines.length - 1 && opts.right ? { text: opts.right, dim: true } : undefined, + panel: true, + }) + } + return rows +} + +// The window of rows on screen, and how many are hidden beyond each edge. +// +// `anchor` is the index of the row pinned to the TOP, or null to follow the +// bottom (the default, so streamed content stays in view). The window is +// always packed FULL: anchored near the end of the list it backs up to fill +// the budget rather than leaving the bottom of the frame empty. +// +// The "… N earlier/newer" markers live inside the budget, so their rows come +// out of the window that needs them — resolved by re-fitting until it stops +// changing (there are at most two, so it settles at once). +// +// GUARANTEE: content rows plus marker rows never exceed `budget`, for any +// input. The whole layout rests on it — one row too many and ink's frame +// outgrows the pane, which scrolls the render region and smears stale rows up +// the terminal. A budget with no room to spare drops a marker rather than +// overflow, which is why showAbove/showBelow are separate from the hidden +// counts. Pure, for tests. +export function rowViewport( + total: number, + budget: number, + anchor: number | null, +): { + start: number + end: number + capacity: number + hiddenAbove: number + hiddenBelow: number + showAbove: boolean + showBelow: boolean +} { + const room = Math.max(1, budget) + if (total === 0) { + return { + start: 0, + end: 0, + capacity: room, + hiddenAbove: 0, + hiddenBelow: 0, + showAbove: false, + showBelow: false, + } + } + // One content row always shows, so the markers can claim what the budget has + // beyond it and no more. + const markerRoom = Math.max(0, room - 1) + let markers = 0 + let start = 0 + let end = 0 + let capacity = room + for (let pass = 0; pass < 3; pass++) { + capacity = room - markers + if (anchor === null) { + end = total + start = Math.max(0, end - capacity) + } else { + start = Math.max(0, Math.min(anchor, total - 1)) + end = Math.min(total, start + capacity) + // Packed full against the bottom edge: back up rather than leave the + // last rows of the frame blank. + if (end === total) start = Math.max(0, total - capacity) + } + const want = (start > 0 ? 1 : 0) + (end < total ? 1 : 0) + const next = Math.min(want, markerRoom) + if (next === markers) break + markers = next + } + // With room for only one marker, "earlier" wins: that there is history above + // is the more useful fact, and following the bottom is the common case. + const showAbove = start > 0 && markers >= 1 + const showBelow = end < total && markers >= (start > 0 ? 2 : 1) + return { + start, + end, + capacity, + hiddenAbove: start, + hiddenBelow: total - end, + showAbove, + showBelow, + } +} + +// 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. +export type ScrollAnchor = { entryKey: string; rowOffset: number } + +// The flat row index an anchor points at, or null when its entry is gone (the +// caller falls back to following the bottom). +export function anchorIndex(rows: readonly TranscriptRow[], anchor: ScrollAnchor): number | null { + const first = rows.findIndex((r) => r.entryKey === anchor.entryKey) + if (first < 0) return null + return Math.max(0, Math.min(first + anchor.rowOffset, rows.length - 1)) +} + +// The anchor for a flat row index. +export function anchorAt(rows: readonly TranscriptRow[], index: number): ScrollAnchor | null { + const row = rows[index] + if (!row) return null + const first = rows.findIndex((r) => r.entryKey === row.entryKey) + return { entryKey: row.entryKey, rowOffset: Math.max(0, index - first) } +} + +// The row range an entry occupies, skipping its leading spacer — that blank +// row is a separator, so bringing an entry to the top of the window should +// land on its first line of content, not on the gap above it. Pure, for tests. +export function entryRange( + rows: readonly TranscriptRow[], + entryKey: string, +): { first: number; last: number } | null { + let first = -1 + let last = -1 + for (const [i, row] of rows.entries()) { + if (row.entryKey !== entryKey) continue + if (first < 0 && row.spacer) continue + if (first < 0) first = i + last = i + } + return first < 0 ? null : { first, last } +} + +// Where the window must sit for `entryKey` to be readable, given where it sits +// now — the ↑/↓ snap. +// +// An entry coming into frame from ABOVE puts its first line at the TOP of the +// window: you read a message from its beginning, with as much of it in front +// of you as fits. So does one too tall to fit whole. An entry arriving from +// BELOW aligns to the bottom edge, the direction it was already travelling. +// One already fully in frame doesn't move the window at all. Returns the flat +// row index to pin to the top, or null to leave the window alone. Pure, for +// tests. +export function snapToEntry( + rows: readonly TranscriptRow[], + entryKey: string, + view: { start: number; end: number }, + capacity: number, +): number | null { + const range = entryRange(rows, entryKey) + if (!range) return null + const height = range.last - range.first + 1 + if (range.first < view.start || height >= capacity) return range.first + if (range.last >= view.end) return Math.max(0, range.last - capacity + 1) + return null +} + +// Agent and user prose rendered as markdown (bold, headings, bullets, tables, +// fenced code), pre-wrapped to the column it will occupy. Only these two kinds +// go through it: tool lines and system notices are the SDK's own formatting, +// where a stray asterisk or pipe is literal text. Items without markdown come +// back untouched, so the common case allocates nothing. Pure, for tests. +export function withRenderedMarkdown(item: TranscriptItem, width: number): TranscriptItem { + if (item.kind !== 'assistant' && item.kind !== 'user') return item + if (!hasMarkdown(item.text)) return item + const rendered = renderMarkdown(item.text, width) + return rendered === item.text ? item : { ...item, text: rendered } +} + +// Messages (user + assistant prose) sit on the lifted panel the composer +// uses; tool chatter and notices stay on the canvas. +function isMessage(item: TranscriptItem): boolean { + return item.kind === 'user' || item.kind === 'assistant' +} + +// Which items collapse when long: tool results and user turns (the latter carry +// the re-injected run context, which is bulky). Assistant prose stays full. +export function isCollapsible(item: TranscriptItem): boolean { + return ( + (item.kind === 'tool_result' || item.kind === 'user') && + item.text.split('\n').length > COLLAPSE_LINES + ) +} + +// The sender icon in the 2-column gutter: ◆ (cyan) marks a message you sent +// (the --prompt initial message included — it's a user message), ● marks the +// assistant's prose (default foreground; the tool-call ● is green + bold, so +// the two never read the same), ✦ (dim) marks system/notice lines — the +// infrastructure speaking. Everything else keeps the SDK's glyph (⎿ results, +// ✻ thinking) or none. The ▶ selection highlight replaces the icon in the +// same slot, so a selected line always reads differently from its resting +// state. Pure, for tests. +export function gutterFor(item: TranscriptItem): string { + if (item.kind === 'user') return '◆' + if (item.kind === 'assistant') return '●' + if (item.kind === 'system' || item.kind === 'notice') return '✦' + return item.gutter ?? '' +} + +// Colour + weight for each transcript item kind, matched loosely to Claude Code. +function styleFor(item: TranscriptItem): { + gutterColor?: string + textColor?: string + dim: boolean + bold: boolean +} { + const kind: ItemKind = item.kind + switch (kind) { + case 'tool': + return { gutterColor: theme.success, bold: true, dim: false } + case 'tool_result': + return { + textColor: item.isError ? theme.error : undefined, + dim: !item.isError, + bold: false, + } + // User copy stays white like the assistant's (the ◆ icon marks the + // sender); cyan text always and only means "the selection is here". + case 'user': + return { gutterColor: theme.foreground, bold: true, dim: false } + case 'error': + return { gutterColor: theme.error, textColor: theme.error, dim: false, bold: false } + case 'summary': + return { + textColor: item.isError ? theme.error : undefined, + dim: true, + bold: false, + } + case 'thinking': + case 'system': + case 'notice': + return { dim: true, bold: false } + case 'assistant': + default: + return { dim: false, bold: false } + } +} diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index dc1661b..e140e59 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -5,17 +5,25 @@ import { cursorLineUp, deliveredUnechoedSends, deriveSandboxState, - estimateItemRows, + lastLines, foldRun, - gutterFor, hookPhrase, humanDuration, reshapeTranscript, - sandboxStepLine, - viewportSlice, - withRenderedMarkdown, - type SandboxStep, + sessionLogText, } from '../src/ui/ConnectApp' +import { + anchorAt, + anchorIndex, + entryRange, + gutterFor, + itemRows, + layOutItems, + rowViewport, + snapToEntry, + withRenderedMarkdown, + type TranscriptRow, +} from '../src/ui/transcriptRows' import stripAnsi from 'strip-ansi' import type { TranscriptItem } from '@ellipsis-dev/sdk/store' @@ -25,6 +33,13 @@ function rec(recordType: string, payload: Record = {}, source = } describe('deriveSandboxState', () => { + // The whole startup story is ONE flat log, in feed order — the shape that + // replaced the old session → sandbox → phase → per-phase-tail tree. + const texts = (state: ReturnType) => + (state?.log ?? []).map((l) => l.text) + const kinds = (state: ReturnType) => + (state?.log ?? []).map((l) => l.kind) + it('returns null before any lifecycle record', () => { expect(deriveSandboxState([], 0)).toBeNull() expect(deriveSandboxState([rec('assistant', {}, 'claude_code')], 0)).toBeNull() @@ -34,7 +49,6 @@ describe('deriveSandboxState', () => { const scheduled = deriveSandboxState([rec('session_scheduled', { source: 'cli' })], 0) expect(scheduled?.headline).toBe('Session scheduled…') expect(scheduled?.done).toBe(false) - expect(scheduled?.sandboxLine).toBeNull() const starting = deriveSandboxState( [ @@ -59,17 +73,7 @@ describe('deriveSandboxState', () => { expect(ready?.sandboxDone).toBe(true) }) - it('carries the config name as its own child line, not in the headline', () => { - const state = deriveSandboxState( - [rec('session_scheduled', { source: 'cli', config_name: 'deployer' })], - 0, - ) - expect(state?.headline).toBe('Session scheduled…') - expect(state?.configName).toBe('deployer') - expect(state?.configCommitSha).toBeNull() - }) - - it('carries the config commit sha when the backend sends it', () => { + it('heads the log with the config, and keeps it across the starting transition', () => { const state = deriveSandboxState( [ rec('session_scheduled', { @@ -77,25 +81,17 @@ describe('deriveSandboxState', () => { config_name: 'deployer', config_commit_sha: 'abc1234def5678', }), - ], - 0, - ) - expect(state?.configCommitSha).toBe('abc1234def5678') - }) - - it('keeps the config name across the starting transition (no flash)', () => { - const state = deriveSandboxState( - [ - rec('session_scheduled', { source: 'cli', config_name: 'deployer' }), rec('session_starting', { attempt: 0, wake_index: 0 }), ], 0, ) + // The config outlives the restart that clears the log below it. expect(state?.headline).toBe('Session starting…') expect(state?.configName).toBe('deployer') + expect(texts(state)[0]).toBe('Using deployer @ abc1234') }) - it('builds the phase timeline from sandbox_phase transitions', () => { + it('logs each phase as ONE line, opened then closed in place', () => { const state = deriveSandboxState( [ rec('sandbox_starting', { repositories: ['o/r'] }), @@ -110,152 +106,71 @@ describe('deriveSandboxState', () => { ], 0, ) - expect(state).not.toBeNull() - expect(state?.done).toBe(false) - expect(state?.sandboxLine).toBe('Sandbox starting…') - expect(state?.steps.map((s) => [s.key, s.status])).toEqual([ - ['image', 'done'], - ['clone', 'running'], + // Not "Preparing image…" AND "Preparing image ✓" — the same line closes. + expect(texts(state)).toEqual([ + 'Starting sandbox…', + 'Preparing image · cached image (1.2s)', + 'Fetching repositories…', ]) - expect(state?.steps[0].label).toBe('Preparing image') - expect(state?.steps[0].note).toBe('cached image (1.2s)') + expect(kinds(state)).toEqual(['step', 'done', 'step']) }) - it('attaches output chunks to the transition-opened step', () => { + it('puts build and setup OUTPUT in the same flat log, in order', () => { const state = deriveSandboxState( [ - rec('sandbox_phase', { phase: 'clone', status: 'started' }), - rec('sandbox_output', { phase: 'clone', step: 'o/r', chunk: 0, lines: ['HEAD is now at x'] }), - rec('sandbox_output', { phase: 'clone', step: 'o/r', chunk: 1, lines: ['done'] }), - ], - 0, - ) - expect(state?.steps).toHaveLength(1) - expect(state?.steps[0].key).toBe('clone') - expect(state?.steps[0].lines).toEqual(['HEAD is now at x', 'done']) - }) - - it('keys per-step transitions (hooks) separately and labels them as hooks', () => { - const state = deriveSandboxState( - [ - rec('sandbox_phase', { phase: 'hooks', step: 'post_clone', status: 'started' }), - rec('sandbox_output', { phase: 'hooks', step: 'post_clone', chunk: 0, lines: ['npm ci'] }), - rec('sandbox_phase', { - phase: 'hooks', - step: 'post_clone', - status: 'completed', - duration_ms: 800, - }), - ], - 0, - ) - expect(state?.steps.map((s) => s.key)).toEqual(['hooks:post_clone']) - expect(state?.steps[0].label).toBe('Post-clone setup') - expect(state?.steps[0].status).toBe('done') - expect(state?.steps[0].note).toBe('(800ms)') - expect(state?.steps[0].lines).toEqual(['npm ci']) - // No bare 'hooks' phase entry ever opens, so hook steps stay flat. - expect(state?.steps[0].child).toBe(false) - }) - - it('nests the image build/container/smoke steps under Preparing image', () => { - const state = deriveSandboxState( - [ - rec('sandbox_starting', { repositories: ['o/r'] }), - rec('sandbox_phase', { phase: 'image', status: 'started' }), rec('sandbox_phase', { phase: 'image', step: 'build', status: 'started' }), - rec('sandbox_output', { - phase: 'image', - step: 'build', - chunk: 0, - lines: ['#1 FROM base'], - }), - rec('sandbox_output', { - phase: 'image', - step: 'build', - chunk: 1, - lines: ['#2 RUN npm ci'], - }), + rec('sandbox_output', { phase: 'image', step: 'build', chunk: 0, lines: ['#1 FROM base'] }), + rec('sandbox_output', { phase: 'image', step: 'build', chunk: 1, lines: ['#2 RUN npm ci'] }), rec('sandbox_phase', { phase: 'image', step: 'build', status: 'completed', duration_ms: 42000, }), - rec('sandbox_phase', { phase: 'image', step: 'container', status: 'started' }), - rec('sandbox_phase', { - phase: 'image', - step: 'container', - status: 'completed', - duration_ms: 829000, - }), - rec('sandbox_phase', { phase: 'image', step: 'smoke', status: 'started' }), - rec('sandbox_phase', { - phase: 'image', - step: 'smoke', - status: 'completed', - duration_ms: 1200, - }), - rec('sandbox_phase', { - phase: 'image', - status: 'completed', - duration_ms: 873000, - detail: { cache_tier: 'full' }, - }), + rec('sandbox_phase', { phase: 'hooks', step: 'post_clone', status: 'started' }), + rec('sandbox_output', { phase: 'hooks', step: 'post_clone', chunk: 0, lines: ['npm ci'] }), ], 0, ) - expect(state?.steps.map((s) => [s.key, s.label, s.status, s.child])).toEqual([ - ['image', 'Preparing image', 'done', false], - ['image:build', 'Building image', 'done', true], - ['image:container', 'Starting container', 'done', true], - ['image:smoke', 'Smoke check', 'done', true], + // This is the point of the flat log: the output you want while a session + // is slow to start is right there, not three keystrokes deep. + expect(texts(state)).toEqual([ + 'Building image (42s)', + '#1 FROM base', + '#2 RUN npm ci', + 'Post-clone setup…', + 'npm ci', ]) - // The live builder log attaches to the build step, not the bare phase. - expect(state?.steps[0].lines).toEqual([]) - expect(state?.steps[1].lines).toEqual(['#1 FROM base', '#2 RUN npm ci']) - expect(state?.steps[1].note).toBe('(42s)') - expect(state?.steps[2].note).toBe('(13m 49s)') - expect(state?.steps[3].note).toBe('(1.2s)') - expect(state?.steps[0].note).toBe('full build (14m 33s)') + expect(kinds(state)).toEqual(['done', 'output', 'output', 'step', 'output']) }) - it('keeps the sandbox_ready total on phase_timings, never the step durations', () => { + it('logs output that arrives with no phase transition to open it', () => { const state = deriveSandboxState( [ - rec('sandbox_starting', {}), - rec('sandbox_phase', { phase: 'image', status: 'started' }), - rec('sandbox_phase', { phase: 'image', step: 'build', status: 'started' }), - rec('sandbox_phase', { - phase: 'image', - step: 'build', - status: 'completed', - duration_ms: 42000, - }), - rec('sandbox_phase', { phase: 'image', status: 'completed', duration_ms: 43000 }), - rec('sandbox_ready', { - cache_tier: 'full', - phase_timings: { image: 43, clone: 17 }, - }), + rec('sandbox_starting'), + rec('sandbox_output', { phase: 'setup', chunk: 0, lines: ['a'] }), + rec('sandbox_output', { phase: 'setup', chunk: 1, lines: ['b', 'c'] }), ], 0, ) - expect(state?.sandboxLine).toBe('Sandbox ready · full build (1m)') + expect(texts(state)).toEqual(['Starting sandbox…', 'a', 'b', 'c']) }) - it('renders unknown image steps verbatim without nesting surprises (open vocabulary)', () => { - const state = deriveSandboxState( - [ - rec('sandbox_phase', { phase: 'image', step: 'warm_cache', status: 'started' }), - ], - 0, - ) - expect(state?.steps[0].label).toBe('warm_cache') - // No bare image entry in this feed, so the step stays flat. - expect(state?.steps[0].child).toBe(false) + it('labels phases through the open vocabulary, unknown ones verbatim', () => { + expect( + texts(deriveSandboxState([rec('sandbox_phase', { phase: 'warmup', status: 'started' })], 0)), + ).toEqual(['Warmup…']) + expect( + texts( + deriveSandboxState( + [rec('sandbox_phase', { phase: 'image', step: 'warm_cache', status: 'started' })], + 0, + ), + ), + ).toEqual(['warm_cache…']) }) - it('marks a failed transition and keeps its duration', () => { + it('marks a failed phase and keeps its duration', () => { const state = deriveSandboxState( [ rec('sandbox_phase', { phase: 'setup', status: 'started' }), @@ -263,38 +178,11 @@ describe('deriveSandboxState', () => { ], 0, ) - expect(state?.steps[0].status).toBe('failed') - expect(sandboxStepLine(state!.steps[0])).toBe('Running setup failed (4s)') - }) - - it('renders unknown phases generically (open vocabulary)', () => { - const state = deriveSandboxState( - [rec('sandbox_phase', { phase: 'warmup', status: 'started' })], - 0, - ) - expect(state?.steps[0].label).toBe('Warmup') + expect(texts(state)).toEqual(['Running setup failed (4s)']) + expect(kinds(state)).toEqual(['failed']) }) - it('infers steps from bare output chunks (feeds without transitions)', () => { - const state = deriveSandboxState( - [ - rec('sandbox_starting'), - rec('sandbox_output', { phase: 'setup', chunk: 0, lines: ['a'] }), - rec('sandbox_output', { phase: 'setup', chunk: 1, lines: ['b', 'c'] }), - rec('sandbox_output', { phase: 'hooks', step: 'post_clone', chunk: 0, lines: ['d'] }), - ], - 0, - ) - expect(state?.steps.map((s) => [s.key, s.status])).toEqual([ - ['setup', 'done'], - ['post_clone', 'running'], - ]) - expect(state?.steps[0].lines).toEqual(['a', 'b', 'c']) - expect(state?.steps[0].label).toBe('Building image') - expect(state?.steps[1].label).toBe('Post-clone setup') - }) - - it('closes on sandbox_ready: sandbox summary line + Session ready! headline', () => { + it('closes on sandbox_ready with the phase_timings total, not step durations', () => { const state = deriveSandboxState( [ rec('session_scheduled', { source: 'cli' }), @@ -311,13 +199,17 @@ describe('deriveSandboxState', () => { ) expect(state?.headline).toBe('Session ready!') expect(state?.done).toBe(true) - expect(state?.sandboxLine).toBe('Sandbox ready · cached image (29s)') expect(state?.sandboxDone).toBe(true) - // A phase still open at ready closes as done. - expect(state?.steps[0].status).toBe('done') + expect(texts(state)).toEqual([ + 'Starting sandbox…', + 'Preparing image…', + 'Sandbox ready · cached image (29s)', + ]) + // A phase still open when the box came up is no longer live. + expect(kinds(state)).toEqual(['step', 'done', 'done']) }) - it('starts a fresh story on a wake: Waking headline, ready via session_resumed', () => { + it('starts a fresh log on a wake, dropping the previous start', () => { const state = deriveSandboxState( [ rec('session_scheduled', { source: 'cli' }), @@ -334,9 +226,7 @@ describe('deriveSandboxState', () => { ) expect(state?.headline).toBe('Waking the session…') expect(state?.done).toBe(false) - expect(state?.sandboxLine).toBe('Sandbox starting…') - expect(state?.steps.map((s) => s.key)).toEqual(['restore']) - expect(state?.steps[0].label).toBe('Restoring workspace') + expect(texts(state)).toEqual(['Starting sandbox…', 'Restoring workspace…']) const resumed = deriveSandboxState( [ @@ -365,7 +255,7 @@ describe('deriveSandboxState', () => { expect(state?.done).toBe(true) }) - it('shows Retrying as the headline on an infra retry', () => { + it('shows Retrying as the headline and drops the failed start log', () => { const state = deriveSandboxState( [ rec('session_starting', { attempt: 0, wake_index: 0 }), @@ -376,9 +266,7 @@ describe('deriveSandboxState', () => { ) expect(state?.headline).toBe('Retrying · sandbox provisioning failed') expect(state?.done).toBe(false) - // The failed start's sandbox children drop with the fresh story. - expect(state?.sandboxLine).toBeNull() - expect(state?.steps).toHaveLength(0) + expect(state?.log).toHaveLength(0) }) it('ignores records at or below the render cursor (--no-records)', () => { @@ -388,36 +276,23 @@ describe('deriveSandboxState', () => { }) }) -describe('sandboxStepLine', () => { - const step = (over: Partial): SandboxStep => ({ - key: 'clone', - label: 'Fetching repositories', - status: 'running', - note: null, - lines: [], - inferred: false, - child: false, - ...over, - }) - - it('shows a running step as its bare label (the log tail renders beneath, not inline)', () => { - expect(sandboxStepLine(step({}))).toBe('Fetching repositories…') - expect(sandboxStepLine(step({ lines: ['a', 'HEAD is now at x'] }))).toBe( - 'Fetching repositories…', - ) - }) - - it('shows a done step with its closing note', () => { - expect(sandboxStepLine(step({ status: 'done' }))).toBe('Fetching repositories') - expect(sandboxStepLine(step({ status: 'done', note: 'cached image (1.2s)' }))).toBe( - 'Fetching repositories · cached image (1.2s)', - ) +describe('lastLines', () => { + const log = Array.from({ length: 25 }, (_, i) => ({ + key: `k${i}`, + kind: 'output' as const, + text: `line ${i}`, + })) + + it('keeps the NEWEST lines — the tail is what you watch during a build', () => { + expect(lastLines(log, 10).map((l) => l.text)).toEqual([ + 'line 15','line 16','line 17','line 18','line 19', + 'line 20','line 21','line 22','line 23','line 24', + ]) }) - it('says failed', () => { - expect(sandboxStepLine(step({ status: 'failed', note: '(4s)' }))).toBe( - 'Fetching repositories failed (4s)', - ) + it('returns everything when the log is shorter than the window', () => { + expect(lastLines(log.slice(0, 3), 10)).toHaveLength(3) + expect(lastLines([], 10)).toEqual([]) }) }) @@ -558,6 +433,126 @@ describe('reshapeTranscript', () => { expect(items[1].text).toBe('turn ended with an error') expect(items[1].isError).toBe(true) }) + + it('logs the session going to sleep and waking, in feed order', () => { + const { items } = reshapeTranscript( + [ + assistant('done for now'), + rec('session_idle'), + rec('session_starting', { wake_index: 1 }), + rec('session_resumed'), + assistant('back'), + ], + 0, + ) + expect(items.map((i) => i.text)).toEqual([ + 'done for now', + 'Session asleep — your next message wakes it', + 'Waking the session…', + 'Session awake — picking up where it left off', + 'back', + ]) + }) + + it('leaves startup detail out of the chat — that story is the startup block', () => { + const { items } = reshapeTranscript( + [ + rec('sandbox_starting'), + rec('sandbox_phase', { phase: 'setup', status: 'started' }), + rec('sandbox_output', { lines: ['installing…'] }), + rec('sandbox_ready', { cache_tier: 'exact' }), + rec('turn_started'), + assistant('hello'), + ], + 0, + ) + expect(items.map((i) => i.text)).toEqual(['hello']) + }) +}) + +describe('sessionLogText', () => { + const lc = (record_type: string, payload: Record = {}) => + ({ feed_seq: 1, source: 'lifecycle', record_type, payload }) + + it('does not log the FIRST start — the startup block tells that story', () => { + expect(sessionLogText(lc('session_starting', {}))).toBeNull() + expect(sessionLogText(lc('session_starting', { wake_index: 0 }))).toBeNull() + }) + + it('logs a wake, which happens long after the startup block settled', () => { + expect(sessionLogText(lc('session_starting', { wake_index: 2 }))).toBe('Waking the session…') + }) + + it('logs an infra retry distinctly from a wake', () => { + expect(sessionLogText(lc('session_starting', { attempt: 1 }))).toContain('transient error') + expect(sessionLogText(lc('session_retrying', { reason: 'node lost' }))).toBe( + 'Retrying · node lost', + ) + }) + + it('logs a cancellation with its reason when there is one', () => { + expect(sessionLogText(lc('session_cancelled', {}))).toBe('Session cancelled') + expect(sessionLogText(lc('session_cancelled', { reason: 'budget' }))).toBe( + 'Session cancelled · budget', + ) + }) + + it('ignores provisioning chatter', () => { + for (const t of ['sandbox_starting', 'sandbox_phase', 'sandbox_output', 'sandbox_ready', 'turn_started']) { + expect(sessionLogText(lc(t))).toBeNull() + } + }) +}) + +describe('layOutItems', () => { + const prose = (key: string): TranscriptItem => ({ key, kind: 'assistant', text: 'hi' }) + const user = (key: string): TranscriptItem => ({ key, kind: 'user', text: 'do it' }) + const call = (key: string): TranscriptItem => ({ key, kind: 'tool', text: 'Bash' }) + const res = (key: string): TranscriptItem => ({ key, kind: 'tool_result', text: 'ok' }) + const fold = (key: string): TranscriptItem => ({ key: `grp:${key}`, kind: 'notice', text: 'Ran 2' }) + + it('nests a call and its result under the message that made them', () => { + const out = layOutItems([prose('a'), call('t1'), res('r1')]) + expect(out.map((p) => [p.item.key, p.indent, p.nested])).toEqual([ + ['a', 0, false], + ['t1', 2, true], + ['r1', 2, true], + ]) + }) + + it('attaches nested lines, so no blank row detaches them from the parent', () => { + const out = layOutItems([prose('a'), call('t1'), res('r1')]) + expect(out.map((p) => p.attach)).toEqual([false, true, true]) + }) + + it('nests a collapsed fold too — it stands in for the run', () => { + const out = layOutItems([prose('a'), fold('t1')]) + expect(out[1]).toMatchObject({ indent: 2, nested: true }) + }) + + it('nests a turn-opening tool call under the user message that prompted it', () => { + const out = layOutItems([user('u'), call('t1')]) + expect(out[1]).toMatchObject({ indent: 2, nested: true }) + }) + + it('leaves a run with no parent above it flat', () => { + // Replayed history can start mid-burst; there is nothing to hang off. + const out = layOutItems([call('t1'), res('r1'), prose('a')]) + expect(out.map((p) => p.nested)).toEqual([false, false, false]) + }) + + it('indents an opened fold\'s children one level FURTHER than the fold', () => { + const out = layOutItems([prose('a'), fold('t1'), call('t1'), res('r1')], { + indentedKeys: new Set(['t1', 'r1']), + }) + expect(out.map((p) => p.indent)).toEqual([0, 2, 4, 4]) + }) + + it('keeps prose, user messages and notices flat', () => { + const notice: TranscriptItem = { key: 'n', kind: 'notice', text: 'Session asleep' } + const out = layOutItems([prose('a'), user('u'), notice]) + expect(out.every((p) => !p.nested && p.indent === 0)).toBe(true) + }) }) describe('gutterFor', () => { @@ -655,64 +650,201 @@ describe('cursorLineDown', () => { }) }) -describe('viewportSlice', () => { - const heights = [2, 3, 1, 1] - - it('follows the bottom, fitting as many entries as the budget allows', () => { - expect(viewportSlice(heights, 5, { type: 'bottom' })).toEqual({ start: 1, end: 4 }) - expect(viewportSlice(heights, 100, { type: 'bottom' })).toEqual({ start: 0, end: 4 }) +describe('rowViewport', () => { + it('follows the bottom by default, filling the window', () => { + expect(rowViewport(10, 4, null)).toMatchObject({ start: 7, end: 10, hiddenBelow: 0 }) + // The "N above" marker costs a row, so only 3 content rows fit in 4. + expect(rowViewport(10, 4, null).hiddenAbove).toBe(7) }) - it('anchors to a top entry when scrolled', () => { - expect(viewportSlice(heights, 4, { type: 'top', index: 1 })).toEqual({ start: 1, end: 3 }) - expect(viewportSlice(heights, 2, { type: 'top', index: 0 })).toEqual({ start: 0, end: 1 }) + it('shows everything when it fits, with no markers', () => { + expect(rowViewport(3, 10, null)).toMatchObject({ + start: 0, + end: 3, + hiddenAbove: 0, + hiddenBelow: 0, + }) }) - it('anchors an entry to the bottom edge for the ↓-snap', () => { - expect(viewportSlice(heights, 4, { type: 'end', index: 2 })).toEqual({ start: 1, end: 3 }) + it('anchors a row to the top when scrolled', () => { + // Rows 4..6 with both markers eating a row each out of the 5-row budget. + expect(rowViewport(20, 5, 4)).toMatchObject({ start: 4, end: 7 }) }) - it('always includes the anchor entry, even when it alone overflows', () => { - expect(viewportSlice([10], 3, { type: 'bottom' })).toEqual({ start: 0, end: 1 }) - expect(viewportSlice([10], 3, { type: 'top', index: 0 })).toEqual({ start: 0, end: 1 }) + it('packs the window full at the bottom edge instead of leaving dead rows', () => { + // Anchoring row 18 of 20 in a 6-row budget would show 2 rows and waste 4; + // it backs up so the frame is full. + const view = rowViewport(20, 6, 18) + expect(view.end).toBe(20) + expect(view.end - view.start).toBe(5) // one row goes to the "above" marker + expect(view.hiddenBelow).toBe(0) }) it('handles an empty list', () => { - expect(viewportSlice([], 5, { type: 'bottom' })).toEqual({ start: 0, end: 0 }) + expect(rowViewport(0, 5, null)).toMatchObject({ start: 0, end: 0 }) + }) + + // The layout's load-bearing invariant: one row too many and ink's frame + // outgrows the pane, which scrolls the render region and smears stale rows. + it('never renders more rows than the budget, for any input', () => { + const bad: string[] = [] + for (let total = 0; total <= 40; total++) { + for (let budget = 1; budget <= 20; budget++) { + const anchors: (number | null)[] = [null] + for (let a = -2; a <= total + 2; a++) anchors.push(a) + for (const anchor of anchors) { + const v = rowViewport(total, budget, anchor) + const rendered = + v.end - v.start + (v.showAbove ? 1 : 0) + (v.showBelow ? 1 : 0) + if (total > 0 && rendered > budget) { + bad.push(`total=${total} budget=${budget} anchor=${anchor}: ${rendered} rows`) + } + if (v.hiddenAbove !== v.start || v.hiddenBelow !== total - v.end) { + bad.push(`counts disagree with slice: ${JSON.stringify(v)}`) + } + if (v.start > v.end) bad.push(`inverted slice: ${JSON.stringify(v)}`) + } + } + } + expect(bad.slice(0, 10)).toEqual([]) + }) + + it('can always reach the very top and the very bottom', () => { + for (let total = 1; total <= 30; total++) { + for (let budget = 1; budget <= 12; budget++) { + // Anchored at row 0 the window starts at the top, with nothing hidden + // above it; following the bottom, nothing is hidden below. + expect(rowViewport(total, budget, 0).hiddenAbove).toBe(0) + expect(rowViewport(total, budget, null).hiddenBelow).toBe(0) + } + } }) }) -describe('estimateItemRows', () => { - it('counts plain lines plus the spacer row', () => { - expect(estimateItemRows({ key: 'a', kind: 'assistant', text: 'hi' }, 80, false)).toBe(1) - expect( - estimateItemRows( - { key: 'a', kind: 'assistant', text: 'hi\nthere', spaceBefore: true }, - 80, - false, - ), - ).toBe(3) +describe('itemRows', () => { + it('spends no rows on vertical padding, so exchanges pack tightly', () => { + const rows = itemRows({ key: 'a', kind: 'assistant', text: 'hi' }, 40, { + clamp: false, + }) + expect(rows).toHaveLength(1) + expect(rows[0].panel).toBe(true) + }) + + it('emits one row per line of a multi-line body', () => { + const rows = itemRows({ key: 'a', kind: 'assistant', text: 'one\ntwo\nthree' }, 40, { + clamp: false, + }) + expect(rows.map((r) => r.spans[0].text)).toEqual(['one', 'two', 'three']) + }) + + it('never emits a row wider than the pane', () => { + const rows = itemRows({ key: 'a', kind: 'assistant', text: 'x'.repeat(200) }, 40, { + clamp: false, + }) + for (const row of rows) { + const width = row.spans.reduce((n, s) => n + stripAnsi(s.text).length, 0) + expect(width).toBeLessThanOrEqual(40) + } }) - it('accounts for wrapping at the given width', () => { - expect(estimateItemRows({ key: 'a', kind: 'assistant', text: 'x'.repeat(100) }, 40, false)).toBe(3) + it('puts the gutter glyph on the first content row only', () => { + const rows = itemRows({ key: 'a', kind: 'user', text: 'one\ntwo' }, 40, { + clamp: false, + }) + const withGutter = rows.filter((r) => r.gutter) + expect(withGutter).toHaveLength(1) + expect(withGutter[0].gutter?.text).toBe('◆') }) - it('counts the clamped body and the +N marker for collapsible items', () => { - const item = { - key: 'r', - kind: 'tool_result' as const, - text: Array.from({ length: 10 }, (_, i) => `l${i}`).join('\n'), - } - expect(estimateItemRows(item, 80, true)).toBe(7) // 6 clamped lines + marker - expect(estimateItemRows(item, 80, false)).toBe(10) + it('leads with a spacer row when the item wants space before it', () => { + const rows = itemRows({ key: 'a', kind: 'notice', text: 'note', spaceBefore: true }, 40, { + clamp: false, + }) + expect(rows[0].spacer).toBe(true) + }) + + it('clamps a long body, marking how many lines are hidden', () => { + const text = Array.from({ length: 10 }, (_, i) => `l${i}`).join('\n') + const item = { key: 'r', kind: 'tool_result' as const, text } + const collapsed = itemRows(item, 40, { clamp: true }) + expect(collapsed).toHaveLength(7) // 6 lines + the "+N lines" marker + // The row carries the COUNT; the renderer writes the hint, because which + // key opens it (→ vs ctrl+r) depends on the highlight — a render concern. + expect(collapsed[6].clampedLines).toBe(4) + expect(itemRows(item, 40, { clamp: false })).toHaveLength(10) }) it('measures visible columns, not escape sequences', () => { // A markdown-rendered line carries ANSI codes that occupy no columns. - // Counting them would over-estimate the height and desync the viewport. - const styled = `${'x'.repeat(30)}` - expect(estimateItemRows({ key: 'a', kind: 'assistant', text: styled }, 40, false)).toBe(1) + // Counting them would over-count rows and desync the window. + const styled = `\u001b[1m${'x'.repeat(30)}\u001b[22m` + const rows = itemRows({ key: 'a', kind: 'assistant', text: styled }, 40, { + clamp: false, + }) + expect(rows.filter((r) => r.spans.length > 0)).toHaveLength(1) + }) +}) + +describe('row anchors', () => { + const rows: TranscriptRow[] = [ + { id: '0', entryKey: 'a', spans: [] }, + { id: '1', entryKey: 'b', spans: [], spacer: true }, + { id: '2', entryKey: 'b', spans: [] }, + { id: '3', entryKey: 'b', spans: [] }, + { id: '4', entryKey: 'c', spans: [] }, + ] + + it('round-trips a row index through an entry-relative anchor', () => { + const anchor = anchorAt(rows, 3) + expect(anchor).toEqual({ entryKey: 'b', rowOffset: 2 }) + expect(anchorIndex(rows, anchor!)).toBe(3) + }) + + it('survives rows being prepended above the anchor', () => { + const anchor = anchorAt(rows, 3)! + const grown = [{ id: 'x', entryKey: 'z', spans: [] }, ...rows] + // Same content row, new flat index — this is what keeps a streamed + // append from sliding the window. + expect(anchorIndex(grown, anchor)).toBe(4) + }) + + it('reports a vanished entry so the caller can follow the bottom', () => { + expect(anchorIndex(rows, { entryKey: 'gone', rowOffset: 0 })).toBeNull() + }) + + it('skips an entry leading spacer, which is a separator not content', () => { + expect(entryRange(rows, 'b')).toEqual({ first: 2, last: 3 }) + }) +}) + +describe('snapToEntry', () => { + // Entry 'b' is 10 rows tall, taller than a 4-row window. + const rows: TranscriptRow[] = [ + { id: 'a', entryKey: 'a', spans: [] }, + ...Array.from({ length: 10 }, (_, i) => ({ id: `b${i}`, entryKey: 'b', spans: [] })), + { id: 'c', entryKey: 'c', spans: [] }, + ] + + it('brings an entry entered from above to the top of the window', () => { + expect(snapToEntry(rows, 'a', { start: 5, end: 9 }, 4)).toBe(0) + }) + + it('shows a too-tall entry from its FIRST line, so it reads from the top', () => { + expect(snapToEntry(rows, 'b', { start: 0, end: 4 }, 4)).toBe(1) + }) + + it('aligns an entry arriving from below to the bottom edge', () => { + // 'c' is one row at index 11, entering a 4-row window that ends at 8. + expect(snapToEntry(rows, 'c', { start: 4, end: 8 }, 4)).toBe(8) + }) + + it('leaves the window alone for an entry already fully in frame', () => { + const short: TranscriptRow[] = [ + { id: 'a', entryKey: 'a', spans: [] }, + { id: 'b', entryKey: 'b', spans: [] }, + { id: 'c', entryKey: 'c', spans: [] }, + ] + expect(snapToEntry(short, 'b', { start: 0, end: 3 }, 3)).toBeNull() }) }) diff --git a/test/markdown.test.ts b/test/markdown.test.ts index b69fd84..674a602 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import stripAnsi from 'strip-ansi' -import { hasMarkdown, renderMarkdown, visibleWidth } from '../src/lib/markdown' +import { fitLines, hasMarkdown, renderMarkdown, visibleWidth } from '../src/lib/markdown' const lines = (text: string): string[] => text.split('\n') const plain = (text: string): string => stripAnsi(text) @@ -106,3 +106,29 @@ describe('visibleWidth', () => { expect(visibleWidth('plain')).toBe(5) }) }) + +describe('fitLines', () => { + it('returns the exact lines the text occupies, none wider than the width', () => { + const out = fitLines('word '.repeat(30).trim(), 20) + expect(out.length).toBeGreaterThan(1) + for (const line of out) expect(visibleWidth(line)).toBeLessThanOrEqual(20) + }) + + it('keeps a short line as one line, and splits on existing newlines', () => { + expect(fitLines('hi', 20)).toEqual(['hi']) + expect(fitLines('a\nb\nc', 20)).toEqual(['a', 'b', 'c']) + }) + + it('measures visible columns, so escapes cost no width', () => { + // 30 visible chars in a 40-column pane: one line, despite the escapes. + expect(fitLines(`\u001b[1m${'x'.repeat(30)}\u001b[22m`, 40)).toHaveLength(1) + }) + + it('truncates a table row instead of reflowing it', () => { + // Box-drawing rows can't move their columns, so they're cut to fit. + const row = `│ ${'a'.repeat(40)} │` + const out = fitLines(row, 20) + expect(out).toHaveLength(1) + expect(visibleWidth(out[0])).toBeLessThanOrEqual(20) + }) +})