diff --git a/src/lib/events.ts b/src/lib/events.ts
index 8649940..2f7eb2d 100644
--- a/src/lib/events.ts
+++ b/src/lib/events.ts
@@ -135,6 +135,13 @@ export function summarizeToolInput(name: string, input: unknown): string {
return oneLine(JSON.stringify(args), 100)
}
+// Human-readable duration from seconds, Claude Code style: "42s" under a
+// minute, then "3m 21s". Whole seconds only ("200.7s" reads as noise).
+export function formatDuration(seconds: number): string {
+ const s = Math.round(seconds)
+ return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`
+}
+
// Flatten a message's `content` to a block list (a bare string becomes one
// text block), so assistant and user turns share one iteration path.
function blocksOf(content: unknown): CCContentBlock[] {
@@ -174,7 +181,7 @@ export function eventToItems(event: CCEvent, keyBase: string): TranscriptItem[]
// A final result event: a dim one-liner capping the turn (cost + duration).
if (type === 'result') {
const bits: string[] = []
- if (typeof event.duration_ms === 'number') bits.push(`${(event.duration_ms / 1000).toFixed(1)}s`)
+ if (typeof event.duration_ms === 'number') bits.push(formatDuration(event.duration_ms / 1000))
if (typeof event.total_cost_usd === 'number') bits.push(`$${event.total_cost_usd.toFixed(2)}`)
const label = event.is_error ? 'turn ended with an error' : 'turn complete'
push({
diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx
index ade70e3..8f4d410 100644
--- a/src/ui/ConnectApp.tsx
+++ b/src/ui/ConnectApp.tsx
@@ -6,6 +6,7 @@ import {
clampLines,
collapseToolRuns,
foldCosts,
+ formatDuration,
isConnectVisibleRecord,
pendingToolCalls,
recordToItems,
@@ -20,8 +21,9 @@ import { VERSION } from '../lib/constants'
// The interactive `agent session connect` UI, modelled on Claude Code: a
// committed transcript that groups tool calls with their results and spaces
-// messages apart, above a live footer with an animated status spinner and a
-// composer that echoes what you send. Rendering shape lives in lib/events.ts
+// messages apart — live activity (✻ Running/Generating) rendered on the
+// transcript block it describes — above a footer with a composer that echoes
+// what you send. Rendering shape lives in lib/events.ts
// (pure); this component owns the data flow, the composer, and the colours.
//
// Data flow: the committed transcript comes from the structured records API
@@ -251,10 +253,20 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
append(
fresh.filter(isConnectVisibleRecord).flatMap((st) => recordToItems(st, `s${st.feed_seq}`)),
)
- // A committed step landed — the streamed overlay is now part of the
- // transcript (or the turn advanced), so drop the live overlay.
- setLiveText('')
- setLiveTokens(null)
+ // Drop the live overlay only when a committed ASSISTANT step (or the
+ // turn's result) lands — those supersede the streamed prose. Other
+ // records (lifecycle rows, your own relayed message) arrive mid-
+ // generation; clearing on them would lose the streamed prefix for
+ // good, since later deltas append to the now-empty overlay.
+ const supersedes = fresh.some((st) => {
+ if (st.source === 'lifecycle') return false
+ const t = (st.payload as CCEvent).type
+ return t === 'assistant' || t === 'result'
+ })
+ if (supersedes) {
+ setLiveText('')
+ setLiveTokens(null)
+ }
}
} catch {
// Transient fetch failure — the next wake/poll retries.
@@ -558,12 +570,28 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
// 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). The list is memoized on [items, expanded], so the elapsed-second
+ // 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.
- const lines = useMemo(() => {
- const visible = expanded ? items : collapseToolRuns(items)
- return visible.map((item) => )
- }, [items, expanded])
+ const { lines, runningHug } = useMemo(() => {
+ const pendingKeys = new Set(pendingTools.map((t) => t.key))
+ const visible = expanded
+ ? items
+ : collapseToolRuns(pendingKeys.size ? items.filter((i) => !pendingKeys.has(i.key)) : items)
+ const last = visible[visible.length - 1]
+ return {
+ lines: visible.map((item) => (
+
+ )),
+ // Whether the live "Running …" line should hug the block above it: in
+ // expanded mode the pending ● call itself is the last line; collapsed,
+ // only when the trailing fold ("Ran N …", key grp:*) is the same burst
+ // the pending call belongs to — otherwise the line opens its own block.
+ runningHug: expanded ? pendingKeys.size > 0 : (last?.key.startsWith('grp:') ?? false),
+ }
+ }, [items, expanded, pendingTools])
// The persistent footer status line, kept minimal: the current status and
// the running spend (cumulative total + the last turn's cost). Session
@@ -571,19 +599,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
const totalStr = `$${(cost.total ?? 0).toFixed(2)}`
const lastStepStr = cost.lastStep != null ? ` (Last step: $${cost.lastStep.toFixed(2)})` : ''
const metaLine = `${status} · ${totalStr} total${lastStepStr}`
- // Three distinct, factual activity signals — never whimsy:
+ // 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 to sit above a composer.
+ // belongs — there's no conversation yet.
// - `generating`: the model is streaming tokens (delta frames flowing) —
- // the ✻ line above the composer, with elapsed + token count.
- // - `runningTool`: a committed tool call awaits its result — the same ✻
- // slot names the tool and ticks its own timer (generating wins if both
- // somehow read true; the model can't stream past an unresolved call).
+ // 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 infraActivity = statusActivityText(status)
const generating = status === 'working' && (liveText !== '' || liveTokens != null)
const generatingBits = [
- formatElapsed(elapsed),
+ formatDuration(elapsed),
...(liveTokens != null ? [`↓ ${formatTokens(liveTokens)} tokens`] : []),
...(inputActive ? ['esc to interrupt'] : []),
].join(' · ')
@@ -593,7 +624,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 = [
- formatElapsed(toolElapsed),
+ formatDuration(toolElapsed),
...(inputActive ? ['esc to interrupt'] : []),
].join(' · ')
@@ -627,7 +658,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
✻{' '}
- {infraActivity}… ({formatElapsed(elapsed)})
+ {infraActivity}… ({formatDuration(elapsed)})
)}
@@ -635,30 +666,38 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
composer + meta to the bottom edge (flexGrow fills the slack). */}
{lines}
+ {/* 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. */}
+ {runningTool && (
+
+
+ ✻{' '}
+
+ {runningToolLabel}… ({runningToolBits})
+
+
+
+ )}
{/* The in-progress assistant response, streamed token-by-token from delta
- frames; replaced by the committed step when it lands. */}
+ frames, with its live status hugging beneath; replaced by the
+ committed step when it lands. */}
{liveText && (
{liveText}
)}
-
-
- {notice && · {notice}}
{generating && (
-
- ✻{' '}
- Generating… ({generatingBits})
-
- )}
- {runningTool && (
-
- ✻{' '}
-
- {runningToolLabel}… ({runningToolBits})
+
+
+ ✻{' '}
+ Generating… ({generatingBits})
-
+
)}
+
+
+ {notice && · {notice}}
{/* The composer, framed by a full-width rule above and below (Claude
Code-style): the › prompt sits between the two lines. Only the top and
bottom borders are drawn, so the rules span the terminal width. */}
@@ -689,11 +728,6 @@ function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
}
-// Elapsed seconds in Claude Code's style: "42s", then "1m 25s" past a minute.
-function formatElapsed(s: number): string {
- return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`
-}
-
// Long bodies collapse to this many lines until ctrl+r expands them.
const COLLAPSE_LINES = 6
diff --git a/test/events.test.ts b/test/events.test.ts
index 9d75ce0..7096b90 100644
--- a/test/events.test.ts
+++ b/test/events.test.ts
@@ -5,6 +5,7 @@ import {
pendingToolCalls,
eventToItems,
foldCosts,
+ formatDuration,
LineBuffer,
parseEventLine,
resultCostUsd,
@@ -151,7 +152,12 @@ describe('eventToItems', () => {
}
const [item] = eventToItems(event, 's5')
expect(item.kind).toBe('summary')
- expect(item.text).toBe('turn complete · 12.3s · $0.04')
+ expect(item.text).toBe('turn complete · 12s · $0.04')
+ })
+
+ it('formats long turn durations human-readably (minutes, not 200.7s)', () => {
+ const [item] = eventToItems({ type: 'result', duration_ms: 200700 }, 's5b')
+ expect(item.text).toBe('turn complete · 3m 21s')
})
it('renders nothing for system events (CC emits an init per query — noise)', () => {
@@ -164,6 +170,18 @@ describe('eventToItems', () => {
})
})
+describe('formatDuration', () => {
+ it('shows whole seconds under a minute, minutes past it', () => {
+ expect(formatDuration(42.3)).toBe('42s')
+ expect(formatDuration(200.7)).toBe('3m 21s')
+ expect(formatDuration(0)).toBe('0s')
+ })
+
+ it('never shows 60s (rounds up into the minute)', () => {
+ expect(formatDuration(59.7)).toBe('1m 0s')
+ })
+})
+
describe('clampLines', () => {
it('leaves short bodies untouched', () => {
expect(clampLines('a\nb', 6)).toEqual({ body: 'a\nb', more: 0 })