diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 5f3f5e0..e4cec3a 100644 Binary files a/src/lib/markdown.ts and b/src/lib/markdown.ts differ 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) + }) +})