diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 4532307..ab67fe3 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -4,11 +4,11 @@ import { render } from 'ink' import { ApiClient } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' import { runAction } from '../lib/output' -import { eventToItems, type CCEvent, type TranscriptItem } from '../lib/events' +import { eventToItems, foldCosts, type CCEvent } from '../lib/events' import { sessionUrl } from '../lib/urls' import { resolveWsBase } from '../lib/ws' import { ConnectApp } from '../ui/ConnectApp' -import type { AgentSession, AgentStep } from '../lib/types' +import type { AgentSession } from '../lib/types' // `agent session connect [sessionId]` — the terminal window into a cloud // session (documents/eng/SESSION_IDE.md §2.6, in the ellipsis monorepo). @@ -102,22 +102,31 @@ export async function runConnect( const canSend = readOnly ? false : c.canSend const reason = readOnly && c.canSend ? 'read-only (--no-input) — following without the composer' : c.reason + const url = sessionUrl(resolveAppBase(), me.customer_login, sessionId) - // A compact header printed once above the live transcript. + // A compact header printed once above the live transcript. The live footer + // keeps the id/url, status, and spend on screen; this is the scrollback record. console.log(`${session.id} · ${session.status}`) - console.log(sessionUrl(resolveAppBase(), me.customer_login, sessionId)) + console.log(url) if (session.agent_config_id) console.log(`config ${session.agent_config_id}`) if (reason) console.log(reason) if (canSend) { console.log('type to send · /stop ends the turn · /exit or Ctrl+C detaches') } - // Fetch the stored steps to seed the transcript (unless --no-steps) and to - // set the live-refresh cursor, so the live updates only append what's new. - // The step `data` is the same Claude Code event shape the UI renders live. + // Fetch the stored steps to seed the transcript (unless --no-steps), the + // live-refresh cursor (so live updates only append what's new), and the + // opening spend. The step `data` is the same Claude Code event shape the UI + // renders live. const steps = await api.getAgentSessionSteps(sessionId) - const initialMaxStepIndex = steps.reduce((m, s) => Math.max(m, s.step_index), -1) - const initialItems = showSteps ? stepsToItems(steps) : [] + const ordered = [...steps].sort( + (a, b) => a.created_at.localeCompare(b.created_at) || a.step_index - b.step_index, + ) + const initialMaxStepIndex = ordered.reduce((m, s) => Math.max(m, s.step_index), -1) + const initialItems = showSteps + ? ordered.flatMap((st) => eventToItems(st.data as CCEvent, `s${st.step_index}`)) + : [] + const initialCost = foldCosts(ordered.map((st) => st.data as CCEvent)) const app = render( React.createElement(ConnectApp, { @@ -131,6 +140,8 @@ export async function runConnect( // *rendering* history, not re-streaming it live. initialMaxStepIndex, initialStatus: session.status, + sessionUrl: url, + initialCost, }), ) await app.waitUntilExit() @@ -139,16 +150,3 @@ export async function runConnect( console.log('\ndetached — the session keeps running (reconnect with the same command)') } } - -// Flatten stored steps into transcript items, in chronological order — the -// stored `data` is the same Claude Code event shape as the live stream. -function stepsToItems(steps: AgentStep[]): TranscriptItem[] { - const ordered = [...steps].sort( - (a, b) => a.created_at.localeCompare(b.created_at) || a.step_index - b.step_index, - ) - const items: TranscriptItem[] = [] - for (const step of ordered) { - items.push(...eventToItems(step.data as CCEvent, `s${step.step_index}`)) - } - return items -} diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 9a035d7..ffb917a 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -219,7 +219,7 @@ export function registerSession(program: Command): void { const session = await api.startAgentSession(req) if (opts.connect) { - await startConnect(api, session) + await startConnect(session) return } @@ -745,67 +745,14 @@ export function registerSession(program: Command): void { ) } -// `start --connect`: wait for the session's sandbox to come live, then drop into -// the semantic connect (render the conversation, follow it live, send messages -// through the inbox — the same as `session connect`). A terminal status before -// RUNNING means the session never got a sandbox (e.g. a preflight/budget gate), -// so there is nothing to connect to. -const CONNECT_READY_TIMEOUT_SECONDS = 120 - -export async function startConnect(api: ApiClient, session: AgentSession): Promise { - const sessionId = session.id - // The sandbox may already be live for a warm session; otherwise show an - // animated wait (the transient line clears before the connect UI takes over, - // so the session id/url aren't printed twice). A terminal status before - // RUNNING means the session never got a sandbox (preflight/budget gate). - if (session.status !== 'running') { - const stop = startWaitSpinner(`starting ${sessionId} — waiting for the sandbox`) - const deadline = Date.now() + CONNECT_READY_TIMEOUT_SECONDS * 1000 - for (;;) { - const s = await api.getAgentSession(sessionId) - if (s.status === 'running') break - if (TERMINAL_STATUSES.has(s.status)) { - const reason = s.status_reason ? ` — ${s.status_reason}` : '' - stop(`session ${s.status} before it became connectable${reason}`) - process.exitCode = 1 - return - } - if (Date.now() > deadline) { - stop( - `timed out waiting for the sandbox; once it is running, connect with:\n` + - ` agent session connect ${sessionId}`, - ) - process.exitCode = 1 - return - } - await sleep(1000) - } - stop() - } - await runConnect(sessionId, true) -} - -// A single-line braille spinner for a pre-connect wait. `stop(final?)` clears -// the animated line and optionally prints a final message in its place. On a -// non-TTY (piped/CI) it prints one static line instead of animating. -function startWaitSpinner(label: string): (final?: string) => void { - if (!process.stdout.isTTY) { - console.log(`${label}…`) - return (final) => { - if (final) console.log(final) - } - } - const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] - let i = 0 - process.stdout.write('\x1b[?25l') // hide cursor - const draw = () => process.stdout.write(`\r\x1b[K${frames[i++ % frames.length]} ${label}…`) - draw() - const timer = setInterval(draw, 80) - return (final) => { - clearInterval(timer) - process.stdout.write('\r\x1b[K\x1b[?25h') // clear the line, restore cursor - if (final) console.log(final) - } +// `start --connect`: drop straight into the semantic connect (render the +// conversation, follow it live, send messages through the inbox — the same as +// `session connect`). The connect UI itself renders the sandbox lifecycle +// (creating sandbox → spawning agent process) as it happens and reports a +// terminal status reached before the sandbox ever ran (a preflight/budget gate), +// so there is nothing to wait for out here. +export async function startConnect(session: AgentSession): Promise { + await runConnect(session.id, true) } // `--watch` entry point: stream the session's output live over WebSocket, and diff --git a/src/lib/events.ts b/src/lib/events.ts index 688a2fe..f1b7f4f 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -249,3 +249,57 @@ export function clampLines(text: string, maxLines: number): { body: string; more if (lines.length <= maxLines) return { body: text, more: 0 } return { body: lines.slice(0, maxLines).join('\n'), more: lines.length - maxLines } } + +// The cumulative session cost (USD) a Claude Code `result` event carries: each +// result caps a turn and reports the running total for the whole session (not +// the turn alone). null for non-result events or a result without a cost. +export function resultCostUsd(event: CCEvent): number | null { + if (event.type !== 'result') return null + return typeof event.total_cost_usd === 'number' ? event.total_cost_usd : null +} + +// Fold a chronological event list into the spend the footer shows: `total` is +// the latest result's cumulative cost; `lastStep` is the delta from the result +// before it — i.e. the cost of the most recent completed turn. Both null until +// the first result lands; for the first result, lastStep equals total. +export function foldCosts(events: CCEvent[]): { + total: number | null + lastStep: number | null +} { + let prev: number | null = null + let total: number | null = null + for (const event of events) { + const cost = resultCostUsd(event) + if (cost == null) continue + prev = total + total = cost + } + const lastStep = total != null && prev != null ? Math.max(0, total - prev) : total + return { total, lastStep } +} + +// A dim, Claude-Code-style one-liner for a session lifecycle status, shown in +// the transcript when the status changes ("creating sandbox", "spawning agent +// process", …). null for statuses that don't warrant their own line. +export function statusSystemLine(status: string): string | null { + switch (status) { + case 'scheduled': + return 'scheduled · waiting for a worker' + case 'creating_sandbox': + return 'creating sandbox' + case 'running': + return 'sandbox ready · spawning agent process' + case 'retrying': + return 'retrying after a transient error' + case 'completed': + return 'session complete' + case 'error': + return 'session errored' + case 'cancelled': + return 'session cancelled' + case 'stopped': + return 'session stopped' + default: + return null + } +} diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 3222c38..89a68bb 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -22,3 +22,13 @@ export function configUrl(appBase: string, accountLogin: string, configId: strin export function cliAuthUrl(appBase: string, userCode: string): string { return `${appBase}/cli-auth?code=${encodeURIComponent(userCode)}` } + +// Wrap `label` in an OSC 8 hyperlink so it opens `url` on click in terminals +// that support it (iTerm2, VS Code, WezTerm, kitty, GNOME); degrades to plain +// text elsewhere. Only emitted to a TTY — piped output stays free of escapes. +export function hyperlink(url: string, label: string, isTty = process.stdout.isTTY): string { + if (!isTty) return label + const OSC = ']8;;' + const ST = '\\' + return `${OSC}${url}${ST}${label}${OSC}${ST}` +} diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 6507ac0..8582eee 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -3,7 +3,17 @@ import { Box, Text, useApp, useInput, useStdin } from 'ink' import Spinner from 'ink-spinner' import { ApiClient, ApiError } from '../lib/api' import { streamSession, StreamUnavailableError, type StreamFrame } from '../lib/ws' -import { clampLines, eventToItems, type CCEvent, type ItemKind, type TranscriptItem } from '../lib/events' +import { + clampLines, + eventToItems, + foldCosts, + resultCostUsd, + statusSystemLine, + type CCEvent, + type ItemKind, + type TranscriptItem, +} from '../lib/events' +import { hyperlink } from '../lib/urls' // The interactive `agent session connect` UI, modelled on Claude Code: a // committed transcript that groups tool calls with their results and spaces @@ -33,6 +43,11 @@ export interface ConnectAppProps { // only append steps newer than what's on screen. initialMaxStepIndex: number initialStatus: string + // The clickable dashboard link for this session (app.ellipsis.dev/…/sessions/{id}), + // shown in the footer status line. + sessionUrl: string + // Spend seeded from the stored steps: cumulative total + the last turn's cost. + initialCost: { total: number | null; lastStep: number | null } } // Statuses in which the agent is actively producing output — drives the spinner. @@ -64,6 +79,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // the turn settles. const [liveText, setLiveText] = useState('') const [liveTokens, setLiveTokens] = useState(null) + // Running spend for the footer: `total` is the cumulative session cost from + // the latest Claude Code result; `lastStep` is the cost of the most recent + // turn (the delta between the last two results). + const [cost, setCost] = useState(props.initialCost) // Live-flow state that must survive re-renders without triggering them. const afterSeq = useRef(0) @@ -74,6 +93,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // The highest step_index committed to the transcript, and the refresh // in-flight / re-run guards so overlapping wakes don't double-append. const maxStep = useRef(props.initialMaxStepIndex) + // The cumulative cost last committed, so a new result's delta = the turn cost. + const costTotal = useRef(props.initialCost.total) + // Whether the sandbox ever reached `running`, so a terminal status *before* + // that (a preflight/budget gate) is reported as a failure, not idle. + const everRunning = useRef(props.initialStatus === 'running') const refreshing = useRef(false) const pendingRefresh = useRef(false) const refreshTimer = useRef | null>(null) @@ -109,6 +133,29 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if (commit.length) setItems((prev) => [...prev, ...commit]) }, []) + // Fold a committed event into the footer spend: a result carries the running + // total, so the turn cost is its delta from the previous total. + const applyCost = useCallback((event: CCEvent): void => { + const total = resultCostUsd(event) + if (total == null) return + const prev = costTotal.current + costTotal.current = total + setCost({ total, lastStep: prev != null ? Math.max(0, total - prev) : total }) + }, []) + + // Append a dim, Claude-Code-style lifecycle line ("creating sandbox", …) when + // the session status changes. + const emitStatusLine = useCallback( + (next: string): void => { + const line = statusSystemLine(next) + if (!line) return + append([ + { key: `st${keyCounter.current++}`, kind: 'system', gutter: '●', text: line, spaceBefore: true }, + ]) + }, + [append], + ) + // Pull the structured steps and append any newer than what's on screen. This // is the sole source of transcript content — the socket only decides *when* // to call it. Guarded so concurrent wakes coalesce into one trailing refresh. @@ -125,7 +172,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ) const fresh = ordered.filter((st) => st.step_index > maxStep.current) if (fresh.length) { - for (const st of fresh) maxStep.current = Math.max(maxStep.current, st.step_index) + for (const st of fresh) { + maxStep.current = Math.max(maxStep.current, st.step_index) + applyCost(st.data as CCEvent) + } append(fresh.flatMap((st) => eventToItems(st.data as CCEvent, `s${st.step_index}`))) // A committed step landed — the streamed overlay is now part of the // transcript (or the turn advanced), so drop the live overlay. @@ -141,7 +191,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { void refreshSteps() } } - }, [api, append, sessionId]) + }, [api, append, applyCost, sessionId]) // Coalesce bursts of wakes into one refresh a beat later. const scheduleRefresh = useCallback((): void => { @@ -161,6 +211,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { lastStatus.current = frame.status setStatus(frame.status) setWorking(isWorkingStatus(frame.status)) + if (frame.status === 'running') everRunning.current = true + emitStatusLine(frame.status) } if (frame.type === 'error') { setNotice(frame.message ?? frame.data ?? 'stream error') @@ -176,7 +228,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } scheduleRefresh() }, - [scheduleRefresh], + [emitStatusLine, scheduleRefresh], ) // Keep the socket attached across reconnects/resume. A keyed session going @@ -201,6 +253,18 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { setLiveTokens(null) setWorking(false) if (outcome.type === 'error') setNotice(`stream error: ${outcome.message}`) + // A terminal failure before the sandbox ever ran is a preflight/budget + // gate — there's no conversation to attend, so report it and exit. + if ( + outcome.type === 'done' && + !everRunning.current && + ['error', 'cancelled', 'stopped'].includes(outcome.status) + ) { + setNotice(`session ${outcome.status} before it became connectable`) + process.exitCode = 1 + exit() + return + } if (canSend) { if (outcome.type === 'done') setNotice('agent idle — send a message to wake it') } else { @@ -223,20 +287,46 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { }) }, [canSend, exit, handleFrame, refreshSteps, sessionId, token, wsBase]) + // A status backstop that doesn't depend on the socket: the lifecycle frames + // (creating_sandbox → running) may arrive before the socket attaches, so poll + // the session too and drive the same transition handling. `lastStatus` + // dedupes against socket frames so a transition is only announced once. + const pollStatus = useCallback(async (): Promise => { + try { + const s = await api.getAgentSession(sessionId) + if (s.status !== lastStatus.current) { + lastStatus.current = s.status + setStatus(s.status) + setWorking(isWorkingStatus(s.status)) + if (s.status === 'running') everRunning.current = true + emitStatusLine(s.status) + } + } catch { + // Transient fetch failure — the next tick retries. + } + }, [api, emitStatusLine, sessionId]) + useEffect(() => { + // Announce the phase we connected during (creating a sandbox, retrying) so + // the lifecycle reads top-to-bottom even when we attach mid-startup. + const s0 = lastStatus.current + if (s0 === 'scheduled' || s0 === 'creating_sandbox' || s0 === 'retrying') emitStatusLine(s0) pump() scheduleRefresh() // catch steps created between the initial fetch and connect // A slow poll backs up the socket wake (and covers a socket that never // connected, so following still updates). The socket wake is the fast path; // this is just a safety net, so it can be gentle. const poll = setInterval(scheduleRefresh, 3000) + const statusPoll = setInterval(() => void pollStatus(), 3000) const controller = abort.current return () => { controller.abort() clearInterval(poll) + clearInterval(statusPoll) if (refreshTimer.current) clearTimeout(refreshTimer.current) } - }, [pump, scheduleRefresh]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pump, scheduleRefresh, pollStatus]) // Tick an elapsed-seconds counter while the agent works — a steady progress // read alongside the live token counter, and the sole liveness cue during a @@ -339,6 +429,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { [items], ) + // The persistent footer status line, Claude-Code-style: the (clickable) + // session id, the current status, and the running spend — cumulative total + // plus the cost of the last turn. + const idLink = hyperlink(props.sessionUrl, sessionId) + const totalStr = `$${(cost.total ?? 0).toFixed(2)}` + const lastStepStr = + cost.lastStep != null ? ` (Last step: $${cost.lastStep.toFixed(2)})` : '' + const metaLine = `${idLink} · ${status} · ${totalStr} total${lastStepStr}` + return ( {lines} @@ -357,20 +456,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {' '} - {status} · {elapsed}s + working · {elapsed}s {liveTokens != null ? ` · ${formatTokens(liveTokens)} tokens` : ''} {inputActive ? ' · esc to interrupt · type to queue a message' : ''} )} - {inputActive ? ( - + {/* 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. */} + {inputActive && ( + {input} - ) : ( - !working && · {canSend ? status : `following ${status}`} )} {queued.length > 0 && ( @@ -381,9 +481,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ))} )} - {inputActive && hasCollapsible && ( - ctrl+r to {expanded ? 'collapse' : 'expand'} long output - )} + + {metaLine} + {inputActive && hasCollapsible + ? ` · ctrl+r to ${expanded ? 'collapse' : 'expand'}` + : ''} + ) diff --git a/test/events.test.ts b/test/events.test.ts index a0d2d08..e5179e9 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import { clampLines, eventToItems, + foldCosts, LineBuffer, parseEventLine, + resultCostUsd, + statusSystemLine, summarizeToolInput, type CCEvent, } from '../src/lib/events' @@ -143,3 +146,63 @@ describe('clampLines', () => { expect(more).toBe(4) }) }) + +describe('resultCostUsd', () => { + it('returns the total_cost_usd of a result event', () => { + expect(resultCostUsd({ type: 'result', total_cost_usd: 0.4381 })).toBe(0.4381) + }) + + it('is null for non-result events and results without a cost', () => { + expect(resultCostUsd({ type: 'assistant' })).toBeNull() + expect(resultCostUsd({ type: 'result' })).toBeNull() + }) +}) + +describe('foldCosts', () => { + it('is all-null with no result events', () => { + expect(foldCosts([{ type: 'assistant' }, { type: 'user' }])).toEqual({ + total: null, + lastStep: null, + }) + }) + + it('makes the first turn cost the whole cumulative total', () => { + expect(foldCosts([{ type: 'result', total_cost_usd: 0.32 }])).toEqual({ + total: 0.32, + lastStep: 0.32, + }) + }) + + it('takes the latest total and the delta from the previous result as the last step', () => { + // The cumulative result totals from the "u up" prod session: 0.41 then 0.44 + // → total 0.44, last turn 0.03. + const events: CCEvent[] = [ + { type: 'result', total_cost_usd: 0.3224 }, + { type: 'result', total_cost_usd: 0.41 }, + { type: 'result', total_cost_usd: 0.4381 }, + ] + const { total, lastStep } = foldCosts(events) + expect(total).toBeCloseTo(0.4381, 4) + expect(lastStep).toBeCloseTo(0.0281, 4) + }) + + it('never reports a negative last step if a total regresses', () => { + const events: CCEvent[] = [ + { type: 'result', total_cost_usd: 0.5 }, + { type: 'result', total_cost_usd: 0.4 }, + ] + expect(foldCosts(events).lastStep).toBe(0) + }) +}) + +describe('statusSystemLine', () => { + it('maps lifecycle statuses to Claude-Code-style lines', () => { + expect(statusSystemLine('creating_sandbox')).toBe('creating sandbox') + expect(statusSystemLine('running')).toBe('sandbox ready · spawning agent process') + expect(statusSystemLine('completed')).toBe('session complete') + }) + + it('is null for an unknown status', () => { + expect(statusSystemLine('nonsense')).toBeNull() + }) +}) diff --git a/test/urls.test.ts b/test/urls.test.ts index 68e63de..03ef97e 100644 --- a/test/urls.test.ts +++ b/test/urls.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { cliAuthUrl, configUrl, sessionUrl } from '../src/lib/urls' +import { cliAuthUrl, configUrl, hyperlink, sessionUrl } from '../src/lib/urls' describe('sessionUrl', () => { it('builds the session detail path scoped by account login', () => { @@ -36,3 +36,18 @@ describe('cliAuthUrl', () => { ) }) }) + +describe('hyperlink', () => { + const ESC = String.fromCharCode(27) + + it('wraps the label in an OSC 8 escape on a TTY', () => { + const url = 'https://app.ellipsis.dev/octocat/sessions/session_8f2c' + expect(hyperlink(url, 'session_8f2c', true)).toBe( + `${ESC}]8;;${url}${ESC}\\session_8f2c${ESC}]8;;${ESC}\\`, + ) + }) + + it('returns the bare label off a TTY, so piped output stays clean', () => { + expect(hyperlink('https://x', 'label', false)).toBe('label') + }) +})