From b97fd997afe8ebb13793fec375f505378808b7cf Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 11:27:04 -0400 Subject: [PATCH 01/13] connect: nest and label the image phase's build/container/smoke steps Backend PR ellipsis#5943 splits the sandbox image phase into three sub-steps on the existing open step vocabulary (sandbox_phase records with step build/container/smoke, build output streaming live). The CLI already rendered them generically; this labels them as sentences (Building image / Starting container / Smoke check) and indents a phase:step entry one level under its bare-phase sibling when one exists, so the startup block reads as a hierarchy under Preparing image. Hook steps have no bare-phase sibling and render unchanged, as do old feeds without step transitions. The sandbox_ready total still sums phase_timings only; step durations ride each record's duration_ms and are never added to it. --- src/ui/ConnectApp.tsx | 47 ++++++++++++++++-- test/connect-app.test.ts | 100 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 457aa96..2fa4cfd 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -985,7 +985,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { rows; the selected phase reads cyan like the transcript highlight. */} - {' '} + {step.child ? ' ' : ' '} {selected ? '›' : ' '} {mark}{' '} {(selected && stepLogsOpen ? logLines : []).map((l, j) => ( - {' '} + {step.child ? ' ' : ' '} {j === 0 && hidden > 0 ? `… +${hidden} earlier · ` : ''} {oneLine(l, 100)} @@ -1334,6 +1334,11 @@ export type SandboxStep = { // 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 } // The startup story as a THREE-LEVEL hierarchy, session-first: the headline // is the SESSION's state ("Session scheduled…" → "Session starting…" → @@ -1444,11 +1449,33 @@ function msLabel(ms: unknown): string | null { return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms` } +// The image phase's provisioning sub-steps as sentences: the Modal +// dockerfile build, the Sandbox.create container start (minutes for a +// multi-GB image), and the post-create smoke test. The step vocabulary is +// open by contract, so unknown steps pass through verbatim. +function imageStepLabel(step: string): string { + switch (step) { + case 'build': + return 'Building image' + case 'container': + return 'Starting container' + case 'smoke': + return 'Smoke check' + default: + return step + } +} + // Human label for a timeline step: hooks sub-items keep their hook phrasing, -// other sub-items (a clone's "owner/repo") read as themselves, whole phases -// go through the SDK's open-vocabulary phase labels. +// image sub-items read as sentences, other sub-items (a clone's +// "owner/repo") read as themselves, whole phases go through the SDK's +// open-vocabulary phase labels. function stepLabel(phase: string, step: string | null): string { - if (step) return phase === 'hooks' ? hookPhrase(step) : step + if (step) { + if (phase === 'hooks') return hookPhrase(step) + if (phase === 'image') return imageStepLabel(step) + return step + } return sandboxPhaseLabel(phase) } @@ -1529,6 +1556,7 @@ export function deriveSandboxState( note: null, lines: [], inferred: false, + child: false, } steps.push(entry) } @@ -1564,6 +1592,7 @@ export function deriveSandboxState( note: null, lines: [], inferred: true, + child: false, } steps.push(entry) } @@ -1591,6 +1620,14 @@ export function deriveSandboxState( 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 diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 87ae32b..3532a74 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -150,6 +150,105 @@ describe('deriveSandboxState', () => { 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_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' }, + }), + ], + 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], + ]) + // 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('42.0s') + expect(state?.steps[2].note).toBe('829.0s') + expect(state?.steps[3].note).toBe('1.2s') + expect(state?.steps[0].note).toBe('full build · 873.0s') + }) + + it('keeps the sandbox_ready total on phase_timings, never the step durations', () => { + 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 }, + }), + ], + 0, + ) + expect(state?.sandboxLine).toBe('Sandbox ready · full build · 1m 0s') + }) + + 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('marks a failed transition and keeps its duration', () => { @@ -293,6 +392,7 @@ describe('sandboxStepLine', () => { note: null, lines: [], inferred: false, + child: false, ...over, }) From 03a07aa70ddfbb702a9ae67d45f49378a8fc5e24 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 11:31:56 -0400 Subject: [PATCH 02/13] connect: render a running step's logs as a 5-line tail beneath it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup block inlined a running step's latest log line to the right of its label (Running setup… · Installing termcolor), while the RUNNING_TAIL_LINES tail only appeared after drilling into the panel. Now every running step shows its live tail as dim lines under the step line; the inline latest-line form is gone, and finished steps' logs stay behind the panel toggle. sandboxBlockRows counts the tails so the viewport budget stays honest. --- src/ui/ConnectApp.tsx | 26 ++++++++++++++++---------- test/connect-app.test.ts | 4 ++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 2fa4cfd..07d57df 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -239,7 +239,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // 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 - // latest output line. Highlighting the block (↑ from the composer) and + // 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). @@ -1000,7 +1000,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} - {(selected && stepLogsOpen ? logLines : []).map((l, j) => ( + {/* 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 →. */} + {(running || (selected && stepLogsOpen) ? logLines : []).map((l, j) => ( {step.child ? ' ' : ' '} {j === 0 && hidden > 0 ? `… +${hidden} earlier · ` : ''} @@ -1270,11 +1273,14 @@ function sandboxBlockRows( const steps = sandbox.steps if (sandbox.sandboxLine != null) rows += 1 + steps.length if (open) rows += 1 // the key hint - if (open && logsOpen && steps.length > 0) { - const i = Math.min(stepCursor, steps.length - 1) - const step = steps[i] + 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 - rows += Math.min(step.lines.length, running ? RUNNING_TAIL_LINES : FINISHED_LOG_LINES) + // 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. + if (running || (open && logsOpen && i === cursor)) { + rows += Math.min(step.lines.length, running ? RUNNING_TAIL_LINES : FINISHED_LOG_LINES) + } } return rows } @@ -1634,12 +1640,12 @@ export function deriveSandboxState( } // One timeline step as its collapsed display line: a running step shows its -// label with the latest log line, a finished one its closing note (cache -// tier, duration), a failed one says so. Pure, for tests. +// label (its live log tail renders as dim lines BENEATH it, not inline), a +// finished one its closing note (cache tier, duration), a failed one says +// so. Pure, for tests. export function sandboxStepLine(step: SandboxStep): string { if (step.status === 'running') { - const last = step.lines[step.lines.length - 1] - return last ? `${step.label}… · ${last}` : `${step.label}…` + return `${step.label}…` } if (step.status === 'failed') { return step.note ? `${step.label} failed · ${step.note}` : `${step.label} failed` diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 3532a74..69475ea 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -396,10 +396,10 @@ describe('sandboxStepLine', () => { ...over, }) - it('shows a running step with its latest output line', () => { + 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… · HEAD is now at x', + 'Fetching repositories…', ) }) From 5b12a77d6b6f50a023eb239d78b728713921f62e Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 11:37:17 -0400 Subject: [PATCH 03/13] connect: keep the startup tree expanded after the session is ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block used to collapse to the single ✓ Session ready! headline once done, with the hierarchy behind →. Now the config line, sandbox summary, and step trace stay on screen as all-✓ lines — the durable record of how the session came up. Logs stay hidden once nothing is running (a running step's live tail requires a running step; finished logs stay behind the → panel). --- src/ui/ConnectApp.tsx | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 07d57df..bf7dbc9 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -531,9 +531,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const byKey = new Map() if (infraActivity || sandbox) { keys.push('sandbox') - heights.push( - sandboxBlockRows(sandbox, sandboxOpen, stepLogsOpen, stepCursor, infraActivity != null), - ) + heights.push(sandboxBlockRows(sandbox, sandboxOpen, stepLogsOpen, stepCursor)) } for (const item of visible) { keys.push(item.key) @@ -903,11 +901,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ✻ Sandbox starting… ✓ Preparing image · incremental build · 3.4s ✻ Running setup… - While in progress the whole hierarchy shows, the live level ticking. - Once ready it COLLAPSES to the single "✓ Session ready!" line — - highlighting it (↑ from the composer) and pressing → drills back - into the hierarchy, →/← on a phase shows/hides its logs. It is the - viewport's first entry, so it scrolls out of frame like any line. */} + While in progress the whole hierarchy shows, the live level ticking + with its log tail. Once ready the hierarchy STAYS, all ✓ with the + logs hidden — the durable trace of how the session came up. + Highlighting it (↑ from the composer) and pressing → opens the + 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 && ( {/* Level 1: the session headline. The › replaces the mark while @@ -933,11 +932,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {/* Levels 2+3: the config line, the sandbox line and its phases — - always visible while starting, behind → once the session is - ready. */} - {sandbox && - (sandbox.configName || sandbox.sandboxLine) && - (!sandbox.done || sandboxOpen || infraActivity) && ( + always visible, live while starting and as the all-✓ trace once + the session is ready (logs behind →). */} + {sandbox && (sandbox.configName || sandbox.sandboxLine) && ( {sandbox.configName && ( @@ -1261,13 +1258,10 @@ function sandboxBlockRows( open: boolean, logsOpen: boolean, stepCursor: number, - infraActive: boolean, ): number { let rows = 1 // the headline const expanded = - sandbox != null && - (sandbox.configName != null || sandbox.sandboxLine != null) && - (!sandbox.done || open || infraActive) + sandbox != null && (sandbox.configName != null || sandbox.sandboxLine != null) if (!expanded) return rows if (sandbox.configName != null) rows += 1 const steps = sandbox.steps @@ -1349,8 +1343,9 @@ export type SandboxStep = { // 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` collapses the -// whole block to the single ✓ headline (drill back in with →). +// 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. export type SandboxState = { // The current top-level line ("Session scheduled…", "Session starting…", // "Waking the session…", "Retrying…", "Session ready!"). @@ -1620,8 +1615,8 @@ export function deriveSandboxState( ].filter((b): b is string => b != null) sandboxLine = ['Sandbox ready', ...bits].join(' · ') sandboxDone = true - // The box coming up is the session-level outcome too: the block - // collapses to the ✓ headline (drill in with →). + // 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 } From 62b2d99d8d85b41cf165fd0e498c6a1728cb9f11 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 11:39:04 -0400 Subject: [PATCH 04/13] connect: indent the log tail two columns past its step label The tail sat flush with the step's label text; nudging it two columns right makes it read as the step's child. --- src/ui/ConnectApp.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index bf7dbc9..bd8e45e 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -999,10 +999,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {/* 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 →. */} + a finished step's logs stay behind →. Indented two + columns past the step's label so the lines read as + its children. */} {(running || (selected && stepLogsOpen) ? logLines : []).map((l, j) => ( - {step.child ? ' ' : ' '} + {step.child ? ' ' : ' '} {j === 0 && hidden > 0 ? `… +${hidden} earlier · ` : ''} {oneLine(l, 100)} From 2ebcf87c7453d919bc525dc09fe05c6c42d55481 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 11:42:56 -0400 Subject: [PATCH 05/13] connect: put the +N earlier elision on its own line above the log tail It was prefixed onto the tail's first log line; now it heads the tail as its own dim line, and sandboxBlockRows counts the extra row. --- src/ui/ConnectApp.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index bd8e45e..146c108 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -967,6 +967,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ? 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' ? ( @@ -1001,11 +1003,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { 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. */} - {(running || (selected && stepLogsOpen) ? logLines : []).map((l, j) => ( + 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) => ( - {step.child ? ' ' : ' '} - {j === 0 && hidden > 0 ? `… +${hidden} earlier · ` : ''} + {logIndent} {oneLine(l, 100)} ))} @@ -1274,8 +1281,10 @@ function sandboxBlockRows( 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)) { - rows += Math.min(step.lines.length, running ? RUNNING_TAIL_LINES : FINISHED_LOG_LINES) + const shown = Math.min(step.lines.length, running ? RUNNING_TAIL_LINES : FINISHED_LOG_LINES) + rows += shown + (step.lines.length > shown ? 1 : 0) } } return rows From 787ac451e993394dfbda782efeac16c55d7eb777 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 11:56:11 -0400 Subject: [PATCH 06/13] connect: right-column step metadata with incremental cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn-complete summary rendered as its own full-width line under the assistant message, showing Claude Code's cumulative session total. Now each turn's closing assistant message keeps to the left 80% of the row and its metadata (duration · step cost) renders right-aligned in the remaining 20%, with the turn complete label dropped. The cost is the step's own: result totals are tracked across the whole feed (--no-records still subtracts hidden history; a lower total is a fresh process after a wake, costing the new total itself). Error summaries keep their label and their own red line, as does a summary with no assistant message to hang on. Live streamed prose wraps in the same 80% column so nothing jumps when the turn commits. --- src/ui/ConnectApp.tsx | 110 ++++++++++++++++++++++++++++++++++----- test/connect-app.test.ts | 74 ++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 12 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 146c108..67ffdb4 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -200,12 +200,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // re-derivations. // Lifecycle records are excluded entirely: the sandbox story renders as the // one-line progress block up top (sandboxProgress), not as transcript rows. - const items = useMemo( - () => - snapshot.records - .filter((r) => r.feed_seq > props.minRenderFeedSeq) - .filter((r) => r.source !== 'lifecycle') - .flatMap((r) => recordToItems(r, `s${r.feed_seq}`)), + // Each turn's closing summary is reshaped into stepMeta — the right-hand + // metadata column on the turn's final assistant message (see + // reshapeTranscript). + const { items, stepMeta } = useMemo( + () => reshapeTranscript(snapshot.records, props.minRenderFeedSeq), [snapshot.records, props.minRenderFeedSeq], ) @@ -536,8 +535,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { for (const item of visible) { keys.push(item.key) byKey.set(item.key, item) + // Assistant messages wrap inside their 80% column (the right 20% is + // the metadata gutter), so their height estimate uses that width. heights.push( - estimateItemRows(item, width, !expanded && !openedKeys.has(item.key)), + estimateItemRows( + item, + item.kind === 'assistant' ? Math.max(20, Math.floor(width * 0.8)) : width, + !expanded && !openedKeys.has(item.key), + ), ) } return { keys, heights, byKey } @@ -571,8 +576,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const viewBudget = useMemo(() => { const width = Math.max(20, termCols - 3) const composerRows = inputActive ? 3 + composer.text.split('\n').length : 0 + // Live prose wraps inside the same 80% column its committed form uses. + const liveWidth = Math.max(20, Math.floor(width * 0.8)) const liveReserve = working - ? 2 + (snapshot.liveText ? Math.ceil(snapshot.liveText.length / width) + 1 : 0) + ? 2 + (snapshot.liveText ? Math.ceil(snapshot.liveText.length / liveWidth) + 1 : 0) : 0 // The in-flight sends render inside the transcript area (below the // slice), so their rows come out of the viewport budget: spacer + @@ -1042,6 +1049,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { - {liveText} + + {liveText} + )} {atBottom && generating && ( @@ -1388,6 +1399,64 @@ type LifecycleRecordLike = { session_message_id?: string | null } +// The committed transcript items, with each turn's closing `result` summary +// reshaped: the "turn complete" label drops, the cost becomes the STEP's own +// (result events carry Claude Code's cumulative session total, tracked +// across the WHOLE feed so a --no-records first turn still subtracts the +// hidden history; a total below the previous one is a fresh process after a +// wake, whose step cost is the new total itself), and a summary directly +// following an assistant message moves into stepMeta — rendered as the +// right-hand metadata column on that message instead of its own line. An +// error summary keeps its label and its own (red) line: an error is content, +// not metadata. A summary with no assistant line to hang on stays a line +// too. Pure, for tests. +export function reshapeTranscript( + records: readonly LifecycleRecordLike[], + minRenderFeedSeq: number, +): { items: TranscriptItem[]; stepMeta: ReadonlyMap } { + const items: TranscriptItem[] = [] + const stepMeta = new Map() + let prevTotalUsd = 0 + for (const r of records) { + if (r.source === 'lifecycle') continue + const p = r.payload + const isResult = r.source === 'claude_code' && p.type === 'result' + let stepBits: string[] | null = null + if (isResult) { + stepBits = [] + if (typeof p.duration_ms === 'number') stepBits.push(formatDuration(p.duration_ms / 1000)) + if (typeof p.total_cost_usd === 'number') { + const step = + p.total_cost_usd >= prevTotalUsd ? p.total_cost_usd - prevTotalUsd : p.total_cost_usd + prevTotalUsd = p.total_cost_usd + stepBits.push(`$${step.toFixed(2)}`) + } + } + if (r.feed_seq <= minRenderFeedSeq) continue + // recordToItems reads only the structural slice (source, record_type, + // payload); its SessionRecordWire param type isn't exported from the + // SDK's store entry, hence the cast. + for (const item of recordToItems( + r as Parameters[0], + `s${r.feed_seq}`, + )) { + if (item.kind === 'summary' && stepBits !== null) { + if (item.isError) { + items.push({ ...item, text: ['turn ended with an error', ...stepBits].join(' · ') }) + continue + } + if (stepBits.length === 0) continue + const prev = items[items.length - 1] + if (prev?.kind === 'assistant') stepMeta.set(prev.key, stepBits.join(' · ')) + else items.push({ ...item, text: stepBits.join(' · ') }) + continue + } + items.push(item) + } + } + return { items, stepMeta } +} + // 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 @@ -1724,11 +1793,15 @@ function styleFor(item: TranscriptItem): { const TranscriptLine = React.memo(function TranscriptLine({ item, + meta, expanded, opened, selected, }: { item: TranscriptItem + // The step's metadata ("4s · $0.10"), attached to a turn's closing + // assistant message and rendered right-aligned in the right-hand column. + meta?: string expanded: boolean // This line was opened in place with → while highlighted (un-clamps it). opened: boolean @@ -1750,6 +1823,10 @@ const TranscriptLine = React.memo(function TranscriptLine({ ? clampLines(item.text, COLLAPSE_LINES) : { body: item.text, more: 0 } + // Assistant messages keep to the left 80% of the row, leaving the right + // 20% as the metadata column (the turn's duration + step cost when this + // message closed one). Other kinds span the full width as before. + const isAssistant = item.kind === 'assistant' return ( @@ -1760,7 +1837,11 @@ const TranscriptLine = React.memo(function TranscriptLine({ {selected ? '›' : gutterFor(item)} - + {clamped.body} {item.detail ? ( @@ -1775,6 +1856,11 @@ const TranscriptLine = React.memo(function TranscriptLine({ )} + {meta != null && ( + + {meta} + + )} ) }) diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 69475ea..04be674 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -9,6 +9,7 @@ import { foldRun, gutterFor, hookPhrase, + reshapeTranscript, sandboxStepLine, viewportSlice, type SandboxStep, @@ -506,6 +507,79 @@ describe('deliveredUnechoedSends', () => { }) }) +describe('reshapeTranscript', () => { + const assistant = (text: string) => + rec('cc', { type: 'assistant', message: { content: [{ type: 'text', text }] } }, 'claude_code') + const result = (over: Record = {}) => + rec( + 'cc', + { type: 'result', duration_ms: 4000, total_cost_usd: 0.1, is_error: false, ...over }, + 'claude_code', + ) + + it('attaches the step meta to the closing assistant message and drops the summary row', () => { + const { items, stepMeta } = reshapeTranscript([assistant('done!'), result()], 0) + expect(items.map((i) => i.kind)).toEqual(['assistant']) + expect(stepMeta.get(items[0].key)).toBe('4s · $0.10') + }) + + it('shows each step its own incremental cost, not the cumulative total', () => { + const { items, stepMeta } = reshapeTranscript( + [ + assistant('one'), + result({ total_cost_usd: 0.1 }), + assistant('two'), + result({ total_cost_usd: 0.25, duration_ms: 2000 }), + ], + 0, + ) + expect(stepMeta.get(items[0].key)).toBe('4s · $0.10') + expect(stepMeta.get(items[1].key)).toBe('2s · $0.15') + }) + + it('treats a lower total as a fresh process (wake reset): the step cost is the new total', () => { + const { items, stepMeta } = reshapeTranscript( + [ + assistant('before the wake'), + result({ total_cost_usd: 0.25 }), + assistant('after the wake'), + result({ total_cost_usd: 0.05 }), + ], + 0, + ) + expect(stepMeta.get(items[1].key)).toBe('4s · $0.05') + }) + + it('subtracts history hidden below the render cursor (--no-records)', () => { + const hidden = [assistant('old'), result({ total_cost_usd: 0.1 })] + const cursor = hidden[hidden.length - 1].feed_seq + const { items, stepMeta } = reshapeTranscript( + [...hidden, assistant('new'), result({ total_cost_usd: 0.18, duration_ms: 3000 })], + cursor, + ) + expect(items.map((i) => i.kind)).toEqual(['assistant']) + expect(stepMeta.get(items[0].key)).toBe('3s · $0.08') + }) + + it('keeps an error summary as its own line, label intact', () => { + const { items, stepMeta } = reshapeTranscript( + [assistant('oops'), result({ is_error: true })], + 0, + ) + expect(items.map((i) => i.kind)).toEqual(['assistant', 'summary']) + expect(items[1].text).toBe('turn ended with an error · 4s · $0.10') + expect(items[1].isError).toBe(true) + expect(stepMeta.size).toBe(0) + }) + + it('keeps the summary as its own line when no assistant message precedes it', () => { + const { items, stepMeta } = reshapeTranscript([result()], 0) + expect(items.map((i) => i.kind)).toEqual(['summary']) + expect(items[0].text).toBe('4s · $0.10') + expect(stepMeta.size).toBe(0) + }) +}) + describe('gutterFor', () => { const item = (kind: TranscriptItem['kind'], gutter?: string): TranscriptItem => ({ key: 'k', kind, text: 'x', spaceBefore: false, gutter }) as TranscriptItem From 5be1854fb01cf7041a738dd2dc03da9e087bf8cd Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 12:12:24 -0400 Subject: [PATCH 07/13] connect: bold the settled session headline Session ready! (and the idle parking line) now reads bold in the default foreground instead of dim, standing out over the dim all-done trace beneath it. While starting, the ticking headline stays dim. --- src/ui/ConnectApp.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 67ffdb4..f8190f7 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -926,7 +926,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ) : ( )}{' '} - + {/* 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. */} From 9f8f3515b205bc1f3c320d27e7d607ee9592c411 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 12:13:18 -0400 Subject: [PATCH 08/13] connect: reorder the footer meta line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status · total · model · session id · config · version, dropping the Last step cost (each step's cost now rides the transcript's metadata column) and the config: prefix. --- src/ui/ConnectApp.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index f8190f7..0fe31b0 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -848,20 +848,19 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { lastVisible.kind === 'tool' || lastVisible.kind === 'tool_result') - // The persistent footer status line: the current status, the running spend - // (cumulative total + the last turn's cost), and the session identity — the - // dashboard link rendered as the session id, the agent config (when the - // session has one), the model, and the CLI version. Command hints live in + // 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 + // column, so the footer carries the total alone. Command hints live in // --help. The total prefers the server's ledger figure (live via the // session frames' cost columns, climbing mid-turn); the CC-derived result // total is the fallback against older backends. const totalStr = `$${(serverCostUsd ?? cost.total ?? 0).toFixed(2)}` - const lastStepStr = cost.lastStep != null ? ` (Last step: $${cost.lastStep.toFixed(2)})` : '' const metaLine = [ - `${statusWord} · ${totalStr} total${lastStepStr}`, - hyperlink(props.sessionUrl, sessionId), - ...(props.configName ? [`config: ${props.configName}`] : []), + `${statusWord} · ${totalStr} total`, ...(props.model ? [props.model] : []), + hyperlink(props.sessionUrl, sessionId), + ...(props.configName ? [props.configName] : []), `v${VERSION}`, ].join(' · ') // Three distinct, factual activity signals — never whimsy. All render IN the From 1d65dbfe7affe5378b4982206869800a52ff0ba2 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 12:16:16 -0400 Subject: [PATCH 09/13] connect: agent prose gets the smaller filled circle, system lines get a dim star Sender icons after review: user stays the cyan filled diamond, the assistant's prose moves from the media-style circle to the smaller filled circle (default foreground; the tool-call circle stays green and bold so they never read the same), and system/notice lines gain a dim four-point star instead of no icon. --- src/ui/ConnectApp.tsx | 15 +++++++++------ test/connect-app.test.ts | 7 ++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 0fe31b0..200bb4b 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -1747,14 +1747,17 @@ function isCollapsible(item: TranscriptItem): boolean { } // 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. Everything else keeps the SDK's glyph (● tool calls, -// ⎿ 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. +// (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 === 'assistant') return '●' + if (item.kind === 'system' || item.kind === 'notice') return '✦' return item.gutter ?? '' } diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 04be674..815f780 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -584,9 +584,11 @@ describe('gutterFor', () => { const item = (kind: TranscriptItem['kind'], gutter?: string): TranscriptItem => ({ key: 'k', kind, text: 'x', spaceBefore: false, gutter }) as TranscriptItem - it('marks user messages ◆ and assistant prose ⏺, overriding the SDK gutter', () => { + it('marks user messages ◆, assistant prose ●, and system lines ✦, overriding the SDK gutter', () => { expect(gutterFor(item('user', '›'))).toBe('◆') - expect(gutterFor(item('assistant'))).toBe('⏺') + expect(gutterFor(item('assistant'))).toBe('●') + expect(gutterFor(item('system'))).toBe('✦') + expect(gutterFor(item('notice'))).toBe('✦') }) it('keeps the SDK glyph for tool activity and none for the rest', () => { @@ -594,7 +596,6 @@ describe('gutterFor', () => { expect(gutterFor(item('tool_result', '⎿'))).toBe('⎿') expect(gutterFor(item('thinking', '✻'))).toBe('✻') expect(gutterFor(item('summary'))).toBe('') - expect(gutterFor(item('notice'))).toBe('') }) }) From 4f6875dc1ba9eb0682a7fce1112e58b3abc6c708 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 12:31:58 -0400 Subject: [PATCH 10/13] connect: one duration format, right-column send states, fix the Generating flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Durations read as parenthesized human components everywhere: (3s), (1m 2s), (1h 3m 30s), with ms kept below one second. One formatter (humanDuration) replaces the SDK formatDuration and the decimal msLabel styles ('42.0s', '1m 0s'). - A pending send's state moves off the message text into the right-hand metadata column as (sending…) / (queued…). - The Working fallback line drops the esc-to-interrupt hint and sinks its elapsed time to the right column. - Accepted sends (delivered, echo record in flight) render ABOVE the live activity lines: the running turn is the response to that message, so Generating no longer flashes in above it during the echo gap. --- src/ui/ConnectApp.tsx | 146 +++++++++++++++++++++++++++------------ test/connect-app.test.ts | 59 ++++++++++------ 2 files changed, 139 insertions(+), 66 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 200bb4b..910371f 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -18,7 +18,6 @@ import { clampLines, collapseToolRuns, foldCosts, - formatDuration, lifecycleText, pendingToolCalls, recordToItems, @@ -879,7 +878,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const liveTokens = snapshot.liveOutputTokens const generating = statusWord === 'working' && (liveText !== '' || liveTokens != null) const generatingBits = [ - formatDuration(elapsed), + humanDuration(elapsed), ...(liveTokens != null ? [`↓ ${formatTokens(liveTokens)} tokens`] : []), ...(inputActive ? ['esc to interrupt'] : []), ].join(' · ') @@ -889,7 +888,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}` : `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})` const runningToolBits = [ - formatDuration(toolElapsed), + humanDuration(toolElapsed), ...(inputActive ? ['esc to interrupt'] : []), ].join(' · ') @@ -938,7 +937,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { record lands, and "Session ready!" must not linger. */} {sandbox?.done && !infraActivity ? sandbox.headline - : `${(!sandbox || sandbox.done ? (infraActivity ?? 'Session starting') : sandbox.headline).replace(/…$/, '')}… (${formatDuration(elapsed)})`} + : `${(!sandbox || sandbox.done ? (infraActivity ?? 'Session starting') : sandbox.headline).replace(/…$/, '')}… (${humanDuration(elapsed)})`} {inputActive && navKey === 'sandbox' && !sandboxOpen && sandbox?.sandboxLine ? ' (→: details)' : ''} @@ -1065,6 +1064,25 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {!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 @@ -1101,26 +1119,33 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} - {/* Your in-flight sends, at the chat's bottom edge the moment you hit - enter: dim ◆ rows stamped (sending…) while the POST is in flight, - (queued) once the server accepts, then FULL COLOUR the moment the - agent takes the message — it holds that spot through the echo gap - (which spans a whole sandbox wake) until the agent's own user-echo - transcript item replaces it. */} + {/* 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.map((q) => ( - - - - ◆ - + inFlightSends + .filter((q) => q.state !== 'accepted') + .map((q) => ( + + + + ◆ + + + + {q.text} + + + {q.state === 'sending' ? '(sending…)' : '(queued…)'} + - - {q.text} - {q.state === 'sending' ? ' (sending…)' : q.state === 'queued' ? ' (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 @@ -1138,14 +1163,17 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { !infraActivity && (awaitingAgent !== null || sendPending) && ( - - {' '} + + + + - {awaitingAgent === 'boot' ? 'Starting the agent' : 'Working'}… ( - {formatDuration(elapsed)} - {inputActive ? ' · esc to interrupt' : ''}) + {awaitingAgent === 'boot' ? 'Starting the agent' : 'Working'}… - + + + ({humanDuration(elapsed)}) + )} @@ -1430,7 +1458,9 @@ export function reshapeTranscript( let stepBits: string[] | null = null if (isResult) { stepBits = [] - if (typeof p.duration_ms === 'number') stepBits.push(formatDuration(p.duration_ms / 1000)) + if (typeof p.duration_ms === 'number') { + stepBits.push(`(${humanDuration(p.duration_ms / 1000)})`) + } if (typeof p.total_cost_usd === 'number') { const step = p.total_cost_usd >= prevTotalUsd ? p.total_cost_usd - prevTotalUsd : p.total_cost_usd @@ -1448,7 +1478,12 @@ export function reshapeTranscript( )) { if (item.kind === 'summary' && stepBits !== null) { if (item.isError) { - items.push({ ...item, text: ['turn ended with an error', ...stepBits].join(' · ') }) + items.push({ + ...item, + text: ['turn ended with an error', stepBits.join(' · ')] + .filter(Boolean) + .join(' '), + }) continue } if (stepBits.length === 0) continue @@ -1531,9 +1566,26 @@ export function deliveredUnechoedSends( return out } +// A duration in seconds as compact human-readable components: "3s", +// "1m 2s", "1h 3m 30s". Zero components drop ("2m", "1h 30s"); sub-minute +// durations always read as seconds ("0s" when nothing has elapsed). The one +// duration format everywhere in the app, always shown parenthesized: +// "(10s)". Pure, for tests. +export function humanDuration(seconds: number): string { + const total = Math.max(0, Math.round(seconds)) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = total % 60 + const bits: string[] = [] + if (h > 0) bits.push(`${h}h`) + if (m > 0) bits.push(`${m}m`) + if (s > 0 || bits.length === 0) bits.push(`${s}s`) + return bits.join(' ') +} + function msLabel(ms: unknown): string | null { if (typeof ms !== 'number' || !isFinite(ms) || ms < 0) return null - return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms` + return ms >= 1000 ? humanDuration(ms / 1000) : `${Math.round(ms)}ms` } // The image phase's provisioning sub-steps as sentences: the Modal @@ -1654,10 +1706,12 @@ export function deriveSandboxState( p.detail && typeof p.detail === 'object' ? (p.detail as Record) : {} - const bits = [cacheTierLabel(detail.cache_tier), msLabel(p.duration_ms)].filter( - (b): b is string => b != null, - ) - entry.note = bits.length ? bits.join(' · ') : null + // "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 } } else if (record.record_type === 'sandbox_output') { seen = true @@ -1695,11 +1749,10 @@ export function deriveSandboxState( (acc, v) => (typeof v === 'number' && isFinite(v) ? acc + v : acc), 0, ) - const bits = [ - cacheTierLabel(p.cache_tier), - totalSeconds > 0 ? formatDuration(Math.round(totalSeconds)) : null, - ].filter((b): b is string => b != null) - sandboxLine = ['Sandbox ready', ...bits].join(' · ') + 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. @@ -1722,16 +1775,17 @@ export function deriveSandboxState( // 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, duration), a failed one says -// so. Pure, for tests. +// 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}…` } - if (step.status === 'failed') { - return step.note ? `${step.label} failed · ${step.note}` : `${step.label} failed` - } - return step.note ? `${step.label} · ${step.note}` : 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. diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 815f780..22e0180 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -9,6 +9,7 @@ import { foldRun, gutterFor, hookPhrase, + humanDuration, reshapeTranscript, sandboxStepLine, viewportSlice, @@ -115,7 +116,7 @@ describe('deriveSandboxState', () => { ['clone', 'running'], ]) expect(state?.steps[0].label).toBe('Preparing image') - expect(state?.steps[0].note).toBe('cached image · 1.2s') + expect(state?.steps[0].note).toBe('cached image (1s)') }) it('attaches output chunks to the transition-opened step', () => { @@ -149,7 +150,7 @@ describe('deriveSandboxState', () => { 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].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) @@ -211,10 +212,10 @@ describe('deriveSandboxState', () => { // 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('42.0s') - expect(state?.steps[2].note).toBe('829.0s') - expect(state?.steps[3].note).toBe('1.2s') - expect(state?.steps[0].note).toBe('full build · 873.0s') + expect(state?.steps[1].note).toBe('(42s)') + expect(state?.steps[2].note).toBe('(13m 49s)') + expect(state?.steps[3].note).toBe('(1s)') + expect(state?.steps[0].note).toBe('full build (14m 33s)') }) it('keeps the sandbox_ready total on phase_timings, never the step durations', () => { @@ -237,7 +238,7 @@ describe('deriveSandboxState', () => { ], 0, ) - expect(state?.sandboxLine).toBe('Sandbox ready · full build · 1m 0s') + expect(state?.sandboxLine).toBe('Sandbox ready · full build (1m)') }) it('renders unknown image steps verbatim without nesting surprises (open vocabulary)', () => { @@ -261,7 +262,7 @@ describe('deriveSandboxState', () => { 0, ) expect(state?.steps[0].status).toBe('failed') - expect(sandboxStepLine(state!.steps[0])).toBe('Running setup failed · 4.0s') + expect(sandboxStepLine(state!.steps[0])).toBe('Running setup failed (4s)') }) it('renders unknown phases generically (open vocabulary)', () => { @@ -308,7 +309,7 @@ describe('deriveSandboxState', () => { ) expect(state?.headline).toBe('Session ready!') expect(state?.done).toBe(true) - expect(state?.sandboxLine).toBe('Sandbox ready · cached image · 29s') + 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') @@ -406,14 +407,14 @@ describe('sandboxStepLine', () => { 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', + expect(sandboxStepLine(step({ status: 'done', note: 'cached image (1s)' }))).toBe( + 'Fetching repositories · cached image (1s)', ) }) it('says failed', () => { - expect(sandboxStepLine(step({ status: 'failed', note: '4.0s' }))).toBe( - 'Fetching repositories failed · 4.0s', + expect(sandboxStepLine(step({ status: 'failed', note: '(4s)' }))).toBe( + 'Fetching repositories failed (4s)', ) }) }) @@ -520,7 +521,7 @@ describe('reshapeTranscript', () => { it('attaches the step meta to the closing assistant message and drops the summary row', () => { const { items, stepMeta } = reshapeTranscript([assistant('done!'), result()], 0) expect(items.map((i) => i.kind)).toEqual(['assistant']) - expect(stepMeta.get(items[0].key)).toBe('4s · $0.10') + expect(stepMeta.get(items[0].key)).toBe('(4s) · $0.10') }) it('shows each step its own incremental cost, not the cumulative total', () => { @@ -533,8 +534,8 @@ describe('reshapeTranscript', () => { ], 0, ) - expect(stepMeta.get(items[0].key)).toBe('4s · $0.10') - expect(stepMeta.get(items[1].key)).toBe('2s · $0.15') + expect(stepMeta.get(items[0].key)).toBe('(4s) · $0.10') + expect(stepMeta.get(items[1].key)).toBe('(2s) · $0.15') }) it('treats a lower total as a fresh process (wake reset): the step cost is the new total', () => { @@ -547,7 +548,7 @@ describe('reshapeTranscript', () => { ], 0, ) - expect(stepMeta.get(items[1].key)).toBe('4s · $0.05') + expect(stepMeta.get(items[1].key)).toBe('(4s) · $0.05') }) it('subtracts history hidden below the render cursor (--no-records)', () => { @@ -558,7 +559,7 @@ describe('reshapeTranscript', () => { cursor, ) expect(items.map((i) => i.kind)).toEqual(['assistant']) - expect(stepMeta.get(items[0].key)).toBe('3s · $0.08') + expect(stepMeta.get(items[0].key)).toBe('(3s) · $0.08') }) it('keeps an error summary as its own line, label intact', () => { @@ -567,7 +568,7 @@ describe('reshapeTranscript', () => { 0, ) expect(items.map((i) => i.kind)).toEqual(['assistant', 'summary']) - expect(items[1].text).toBe('turn ended with an error · 4s · $0.10') + expect(items[1].text).toBe('turn ended with an error (4s) · $0.10') expect(items[1].isError).toBe(true) expect(stepMeta.size).toBe(0) }) @@ -575,7 +576,7 @@ describe('reshapeTranscript', () => { it('keeps the summary as its own line when no assistant message precedes it', () => { const { items, stepMeta } = reshapeTranscript([result()], 0) expect(items.map((i) => i.kind)).toEqual(['summary']) - expect(items[0].text).toBe('4s · $0.10') + expect(items[0].text).toBe('(4s) · $0.10') expect(stepMeta.size).toBe(0) }) }) @@ -599,6 +600,24 @@ describe('gutterFor', () => { }) }) +describe('humanDuration', () => { + it('reads as compact h/m/s components, dropping zero parts', () => { + expect(humanDuration(0)).toBe('0s') + expect(humanDuration(3)).toBe('3s') + expect(humanDuration(62)).toBe('1m 2s') + expect(humanDuration(120)).toBe('2m') + expect(humanDuration(3600)).toBe('1h') + expect(humanDuration(3810)).toBe('1h 3m 30s') + expect(humanDuration(5400)).toBe('1h 30m') + }) + + it('rounds fractional seconds and clamps negatives', () => { + expect(humanDuration(1.2)).toBe('1s') + expect(humanDuration(59.7)).toBe('1m') + expect(humanDuration(-5)).toBe('0s') + }) +}) + describe('hookPhrase', () => { it('maps known step/phase keys and passes unknown ones through', () => { expect(hookPhrase('setup')).toBe('Building image') From ab986d293b072ba303b6b9d718596ecd520bdc0e Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 12:33:23 -0400 Subject: [PATCH 11/13] connect: keep sub-5s precision in durations humanDuration scales precision with size: under 1s reads as milliseconds (428ms), under 5s keeps one decimal (1.2s, trimming a trailing .0), and longer durations stay whole h/m/s components. --- src/ui/ConnectApp.tsx | 22 +++++++++++++++------- test/connect-app.test.ts | 17 ++++++++++++----- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 910371f..5e2ecf6 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -1566,13 +1566,21 @@ export function deliveredUnechoedSends( return out } -// A duration in seconds as compact human-readable components: "3s", -// "1m 2s", "1h 3m 30s". Zero components drop ("2m", "1h 30s"); sub-minute -// durations always read as seconds ("0s" when nothing has elapsed). The one -// duration format everywhere in the app, always shown parenthesized: -// "(10s)". Pure, for tests. +// A duration in seconds as compact human-readable components. Precision +// scales down with size: under 1s reads as milliseconds ("428ms"), under 5s +// keeps one decimal ("1.2s", trimming a trailing .0), and everything longer +// reads as whole h/m/s components with zero parts dropped ("10s", "1m 2s", +// "2m", "1h 3m 30s"). The one duration format everywhere in the app, always +// shown parenthesized: "(10s)". Pure, for tests. export function humanDuration(seconds: number): string { - const total = Math.max(0, Math.round(seconds)) + const clamped = Math.max(0, seconds) + if (clamped === 0) return '0s' + if (clamped < 1) return `${Math.round(clamped * 1000)}ms` + if (clamped < 5) { + const s = clamped.toFixed(1) + return s.endsWith('.0') ? `${Math.round(clamped)}s` : `${s}s` + } + const total = Math.round(clamped) const h = Math.floor(total / 3600) const m = Math.floor((total % 3600) / 60) const s = total % 60 @@ -1585,7 +1593,7 @@ export function humanDuration(seconds: number): string { function msLabel(ms: unknown): string | null { if (typeof ms !== 'number' || !isFinite(ms) || ms < 0) return null - return ms >= 1000 ? humanDuration(ms / 1000) : `${Math.round(ms)}ms` + return humanDuration(ms / 1000) } // The image phase's provisioning sub-steps as sentences: the Modal diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 22e0180..b658630 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -116,7 +116,7 @@ describe('deriveSandboxState', () => { ['clone', 'running'], ]) expect(state?.steps[0].label).toBe('Preparing image') - expect(state?.steps[0].note).toBe('cached image (1s)') + expect(state?.steps[0].note).toBe('cached image (1.2s)') }) it('attaches output chunks to the transition-opened step', () => { @@ -214,7 +214,7 @@ describe('deriveSandboxState', () => { 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('(1s)') + expect(state?.steps[3].note).toBe('(1.2s)') expect(state?.steps[0].note).toBe('full build (14m 33s)') }) @@ -407,8 +407,8 @@ describe('sandboxStepLine', () => { 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 (1s)' }))).toBe( - 'Fetching repositories · cached image (1s)', + expect(sandboxStepLine(step({ status: 'done', note: 'cached image (1.2s)' }))).toBe( + 'Fetching repositories · cached image (1.2s)', ) }) @@ -601,6 +601,13 @@ describe('gutterFor', () => { }) describe('humanDuration', () => { + it('scales precision down with size: ms under 1s, one decimal under 5s', () => { + expect(humanDuration(0.428)).toBe('428ms') + expect(humanDuration(1.2)).toBe('1.2s') + expect(humanDuration(4.7)).toBe('4.7s') + expect(humanDuration(3)).toBe('3s') + }) + it('reads as compact h/m/s components, dropping zero parts', () => { expect(humanDuration(0)).toBe('0s') expect(humanDuration(3)).toBe('3s') @@ -612,7 +619,7 @@ describe('humanDuration', () => { }) it('rounds fractional seconds and clamps negatives', () => { - expect(humanDuration(1.2)).toBe('1s') + expect(humanDuration(1.2)).toBe('1.2s') expect(humanDuration(59.7)).toBe('1m') expect(humanDuration(-5)).toBe('0s') }) From 887d3ef8ba8f7e9977565cebcd276e403c3a6378 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 12:39:00 -0400 Subject: [PATCH 12/13] connect: opening Connected line, live readouts in the right column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The app opens with a dim '✦ Connected to ellipsis.dev' line above the startup story, the name hyperlinking to this session's dashboard page. - The Generating and Running-tool lines move their parenthesized readouts (elapsed, token count) to the right-hand metadata column like the Working line, dropping the esc-to-interrupt hint with them. --- src/ui/ConnectApp.tsx | 49 ++++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 5e2ecf6..1c2da09 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -877,20 +877,17 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { 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`] : []), - ...(inputActive ? ['esc to interrupt'] : []), ].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(', ')})` - const runningToolBits = [ - humanDuration(toolElapsed), - ...(inputActive ? ['esc to interrupt'] : []), - ].join(' · ') return ( @@ -898,9 +895,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { accounting quirks and the post-exit sign-off so the first content line never scrolls out of the window. */} - {/* No banner: session identity (dashboard link, model, version) lives in - the footer meta line, so the transcript starts right under the top - padding and nothing is printed to scrollback before the app. */} + {/* 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… @@ -914,6 +911,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { 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. The name links + to this session's dashboard page. */} + + ✦ Connected to {hyperlink(props.sessionUrl, 'ellipsis.dev')} + {/* Level 1: the session headline. The › replaces the mark while highlighted (same 1-char slot), so the header never shifts. */} @@ -1089,12 +1091,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { only render while the viewport follows the bottom. */} {atBottom && runningTool && ( - - {' '} - - {runningToolLabel}… ({runningToolBits}) - - + + + + + {runningToolLabel}… + + + ({humanDuration(toolElapsed)}) + )} {/* The in-progress assistant response, streamed token-by-token from delta @@ -1113,10 +1118,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} {atBottom && generating && ( - - {' '} - Generating… ({generatingBits}) - + + + + + Generating… + + + ({generatingBits}) + )} {/* Your not-yet-taken sends, at the chat's bottom edge the moment @@ -1313,7 +1323,8 @@ function sandboxBlockRows( logsOpen: boolean, stepCursor: number, ): number { - let rows = 1 // the headline + // The "Connected to ellipsis.dev" opener + its blank row, then the headline. + let rows = 3 const expanded = sandbox != null && (sandbox.configName != null || sandbox.sandboxLine != null) if (!expanded) return rows From c294b38a4b24ecf644b697d51a776d6c38182af6 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Thu, 23 Jul 2026 12:40:52 -0400 Subject: [PATCH 13/13] connect: plain bold opener line The OSC 8 hyperlink got broken by ink's wrapping and swallowed the label; the opener is now plain 'Connected to ellipsis.dev' in bold, and the clickable dashboard link stays in the footer. --- src/ui/ConnectApp.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 1c2da09..19a49fb 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -911,10 +911,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { 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. The name links - to this session's dashboard page. */} + {/* 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 {hyperlink(props.sessionUrl, 'ellipsis.dev')} + ✦ Connected to ellipsis.dev {/* Level 1: the session headline. The › replaces the mark while highlighted (same 1-char slot), so the header never shifts. */}