From 591a9993684f4cd2db76d36d432c92b5312fd1cf Mon Sep 17 00:00:00 2001 From: hbrooks Date: Tue, 21 Jul 2026 23:02:48 -0400 Subject: [PATCH] Fill the window without scrollback, sandbox startup drill-down, --rebuild - Bare `agent` (and `session connect`) no longer scrolls the sandbox notes out of view: the app soft-clears to the top of a fresh window (prior content scrolls into scrollback), pads the top so terminal row quirks and the post-exit sign-off eat padding instead of the first line, and the "using config" preamble moved into the app notice so nothing prints before the first paint. - Sandbox startup renders as one header with a single current-phase line rewritten in place; ctrl+s opens the full step list, arrows drill into a step's logs (live 5-line tail while it runs, stored log once finished). Lifecycle records no longer render as transcript rows. - `agent session start --rebuild` (so also bare `agent --rebuild`) sends force_rebuild to skip the sandbox image cache on the initial provision. No-op until the backend field ships (unknown fields are ignored). - package.json version matches the released v1.1.0 tag, which was cut without the bump commit, so dev builds stop reporting v1.0.0. --- package.json | 2 +- src/commands/connect.ts | 21 +++- src/commands/session.tsx | 26 ++++- src/lib/types.ts | 5 + src/ui/ConnectApp.tsx | 242 +++++++++++++++++++++++++++++++++------ test/connect-app.test.ts | 98 ++++++++++++++++ 6 files changed, 352 insertions(+), 42 deletions(-) create mode 100644 test/connect-app.test.ts diff --git a/package.json b/package.json index 1a4edbb..ab3dfa1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ellipsis/cli", - "version": "1.0.0", + "version": "1.1.0", "description": "Ellipsis agent CLI: drive the Ellipsis cloud from your terminal", "license": "MIT", "type": "module", diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 7574931..1d24d92 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -90,6 +90,10 @@ export async function runConnect( sessionId: string, showRecords: boolean, readOnly = false, + // An extra opening notice from the caller (e.g. `start --connect` reporting + // which config the defaults ladder picked) — shown in the app instead of + // printed beforehand, which would land in scrollback behind the app. + startupNotice?: string, ): Promise { const api = new ApiClient() const token = requireToken() @@ -101,6 +105,7 @@ 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 notice = [startupNotice, reason].filter(Boolean).join(' · ') || null const url = sessionUrl(resolveAppBase(), me.customer_login, sessionId) // No scrollback preamble: the app owns the whole surface, Claude Code-style. @@ -122,6 +127,14 @@ export async function runConnect( // Written by the app when it exits because the conversation closed (terminal; // nothing left to reconnect to), so the detach sign-off below stays honest. const exitState = { closed: false } + // Start the app at the top of a fresh window: newlines scroll whatever is on + // screen (the shell prompt, anything a caller printed) into scrollback, then + // the cursor homes to row 1. Without this the first paint begins mid-screen, + // overflows the window, and the app's opening lines (the sandbox startup + // notes) end up stranded above the fold. + if (process.stdout.isTTY) { + process.stdout.write('\n'.repeat(process.stdout.rows ?? 24) + '\x1b[H') + } const app = render( React.createElement(ConnectApp, { api, @@ -131,7 +144,7 @@ export async function runConnect( canSend, minRenderFeedSeq: showRecords ? 0 : store.cursor, sessionUrl: url, - initialNotice: reason ?? null, + initialNotice: notice, // The session's one model, fixed at creation (backend tokens_model). model: typeof session.tokens_model === 'string' ? session.tokens_model : null, exitState, @@ -141,7 +154,9 @@ export async function runConnect( if (canSend && !exitState.closed) { // The session keeps running after a detach; hand back the exact command - // that re-opens this conversation. - console.log(`\nresume with: agent session connect ${sessionId}`) + // that re-opens this conversation. No leading newline: the single row this + // line scrolls is absorbed by the app's top padding (see ConnectApp), so + // the sign-off never scrolls the app's first content line out of the window. + console.log(`resume with: agent session connect ${sessionId}`) } } diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 06442ce..b14cef0 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -123,6 +123,10 @@ export function registerSession(program: Command): void { .option('--cpu ', 'sandbox vCPUs (e.g. 2 or 0.5)', toNumber) .option('--memory ', 'sandbox memory (e.g. 8GB)') .option('--timeout ', 'sandbox timeout (e.g. 30m or 1h)') + .option( + '--rebuild', + 'skip the sandbox image cache: fresh full build (image layers, clones, image.setup), whose snapshot refreshes the cache', + ) .option('--budget ', 'per-run spend limit in USD for this session (limits.run)', toNumber) .option( '-p, --prompt ', @@ -163,6 +167,7 @@ export function registerSession(program: Command): void { cpu?: number memory?: string timeout?: string + rebuild?: boolean budget?: number prompt?: string metadata: Record @@ -224,6 +229,9 @@ export function registerSession(program: Command): void { // Appended to the initial user query at build time; gives this // session instructions on top of the config's shared system prompt. if (promptText) req.prompt = promptText + // Skip the image cache for the initial provision (wakes cache as + // usual); the fresh build's snapshot becomes the new cache entry. + if (opts.rebuild) req.force_rebuild = true // A promptless connect (a bare `agent`) starts the session idle: // no fabricated kickoff message, Claude Code waits at the prompt // for whatever is typed into the composer, like a local `claude`. @@ -236,11 +244,14 @@ export function registerSession(program: Command): void { // Say which agent the server picked when it came from the defaults // ladder, so a bare `agent` never silently runs an unexpected config. - if (!opts.json && session.resolved_config_name) { + // The connect UI shows it as its opening notice (anything printed + // before the app would land in scrollback); every other mode prints it. + let configNote: string | undefined + if (session.resolved_config_name) { if (session.resolution_source === 'repo_default') { - console.log(`using config "${session.resolved_config_name}" (repo default)`) + configNote = `using config "${session.resolved_config_name}" (repo default)` } else if (session.resolution_source === 'account_default') { - console.log(`using config "${session.resolved_config_name}" (account default)`) + configNote = `using config "${session.resolved_config_name}" (account default)` } } @@ -250,6 +261,7 @@ export function registerSession(program: Command): void { const ellipsisBlock = (session.agent_config as Record | undefined) ?.ellipsis as { interactive?: boolean } | undefined if (ellipsisBlock?.interactive === false) { + if (configNote) console.log(configNote) console.log( 'this agent is not interactive; watching output instead of connecting', ) @@ -258,10 +270,12 @@ export function registerSession(program: Command): void { await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, false) return } - await startConnect(session) + await startConnect(session, configNote) return } + if (!opts.json && configNote) console.log(configNote) + if (opts.watch) { if (!opts.json) { console.log(`✓ started session ${session.id}`) @@ -794,8 +808,8 @@ export function registerSession(program: Command): void { // (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) +export async function startConnect(session: AgentSession, notice?: string): Promise { + await runConnect(session.id, true, false, notice) } // `--watch` entry point: stream the session's output live over WebSocket, and diff --git a/src/lib/types.ts b/src/lib/types.ts index 70d97c9..272a7d3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -209,6 +209,11 @@ export interface StartAgentSessionRequest { // bare `agent`). Mutually exclusive with prompt; the server ignores it when // the resolved config is not interactive (that session runs its workflow). idle_start?: boolean + // Skip the sandbox image cache for this session's initial provision: a + // fresh full build (image layers + clone + image.setup from scratch), + // whose snapshot then refreshes the cache for later runs. Wakes of a + // durable session provision through the cache as usual. The --rebuild flag. + force_rebuild?: boolean } // Replay payload for POST /v1/sessions/{id}/replay. Re-runs an existing diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index afaf904..ffa3d26 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -18,7 +18,6 @@ import { collapseToolRuns, foldCosts, formatDuration, - isConnectVisibleRecord, pendingToolCalls, recordToItems, setupOutputHook, @@ -88,6 +87,11 @@ function isWorkingStatus(status: string): boolean { return ['scheduled', 'starting', 'working', 'retrying'].includes(status) } +// Blank rows rendered above the app's first line: two of visual breathing +// room above the ✻ startup header, plus two of sacrificial slack — see the +// termRows comment in ConnectApp for what the slack absorbs. +const TOP_PAD = 4 + // One local send awaiting server acknowledgement: messageId is null while the // POST is in flight, then the created SessionMessage's id (protocol v2 §4.2) — // the chip retires the moment the store acknowledges that id (a messages @@ -102,9 +106,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const { stdout } = useStdout() // Terminal height, tracked across resizes, so the app fills the whole - // window Claude Code-style: banner at top, composer + meta pinned to the - // bottom, the transcript growing through the space between. One row is - // left for the shell cursor so the first paint never scrolls. + // window Claude Code-style: composer + meta pinned to the bottom edge (one + // row below is left for the shell cursor), the transcript growing through + // the space between, and TOP_PAD blank rows of padding above the first + // line. The padding is deliberate slack: terminals that consume an extra + // row (observed in practice) and the caller's post-exit sign-off line + // ("resume with: …") each scroll one padding row into scrollback instead + // of the app's first line, so the sandbox startup notes stay visible. const [termRows, setTermRows] = useState(stdout?.rows ?? 24) useEffect(() => { if (!stdout) return @@ -154,11 +162,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // The committed transcript, derived from the store's record log. Keys ride // feed_seq (the shared per-session order), so items are stable across // 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(isConnectVisibleRecord) + .filter((r) => r.source !== 'lifecycle') .flatMap((r) => recordToItems(r, `s${r.feed_seq}`)), [snapshot.records, props.minRenderFeedSeq], ) @@ -185,21 +195,23 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ) : null - // The setup script's latest output line while the sandbox spawns (streamed - // sandbox_setup_output lifecycle chunks) — the sub-line under "Starting - // sandbox…" that says WHAT the box is doing. sandbox_ready retires it. - const setupLine = useMemo(() => { - for (let i = snapshot.records.length - 1; i >= 0; i--) { - const record = snapshot.records[i] - if (record.source !== 'lifecycle') continue - if (record.record_type === 'sandbox_ready') return null - if (record.record_type === 'sandbox_setup_output') { - const line = setupOutputLine(record.payload) - return line ? `${setupOutputHook(record.payload)} · ${line}` : null - } - } - return null - }, [snapshot.records]) + // The sandbox startup story, derived from the lifecycle records of the + // latest start (a sandbox_starting record resets it, so a wake tells a + // fresh story): the ordered setup steps with their full log lines, plus + // whether the box reached ready. Collapsed, it renders as ONE line showing + // the current phase, rewritten in place ("Building image…" → "Post-clone + // setup… · " → "Ready!"); ctrl+s opens the step list + // and arrow keys drill into a step's logs (a running step shows a live + // 5-line tail, a finished one its stored log). The block persists after + // startup as the durable trace (the sandbox_ready transcript notice is + // suppressed below in its favour). + const sandbox = useMemo( + () => deriveSandboxState(snapshot.records, props.minRenderFeedSeq), + [snapshot.records, props.minRenderFeedSeq], + ) + const [sandboxOpen, setSandboxOpen] = useState(false) + const [stepCursor, setStepCursor] = useState(0) + const [stepLogsOpen, setStepLogsOpen] = useState(false) // Bodies of the server's PENDING inbox messages — the durable queued signal. const serverQueued = useMemo( @@ -420,6 +432,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return } if (key.escape) { + // Modal-first: an open sandbox panel closes before esc means "stop". + if (sandboxOpen) { + setSandboxOpen(false) + setStepLogsOpen(false) + return + } if (working) submit('/stop') return } @@ -427,6 +445,32 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { setExpanded((v) => !v) return } + // ctrl+s opens the sandbox step list; ↑/↓ pick a step, → opens its + // logs, ← closes them. Opening lands on the newest (= running) step. + if (key.ctrl && ch === 's') { + setSandboxOpen((v) => !v) + setStepLogsOpen(false) + setStepCursor(Math.max(0, (sandbox?.steps.length ?? 0) - 1)) + return + } + if (sandboxOpen) { + if (key.upArrow) { + setStepCursor((c) => Math.max(0, c - 1)) + return + } + if (key.downArrow) { + setStepCursor((c) => Math.min(Math.max(0, (sandbox?.steps.length ?? 0) - 1), c + 1)) + return + } + if (key.rightArrow) { + setStepLogsOpen(true) + return + } + if (key.leftArrow) { + setStepLogsOpen(false) + return + } + } if (key.backspace || key.delete) { setInput((p) => p.slice(0, -1)) return @@ -510,29 +554,82 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ].join(' · ') return ( - + + {/* Top padding — see the termRows comment: absorbs terminal row- + accounting quirks and the post-exit sign-off so the first content + line never scrolls out of the window. */} + {/* No banner: session identity (dashboard link, model, version) lives in - the footer meta line, so the transcript starts at the top edge and - nothing is printed to scrollback before the app. */} - {/* Sandbox spawn/wake progress, at the top where startup belongs; - re-renders in place and disappears once the session is live. */} - {infraActivity && ( + the footer meta line, so the transcript starts right under the top + padding and nothing is printed to scrollback before the app. */} + {/* Sandbox spawn/wake progress, at the top where startup belongs: the + ✻ header (ticking while infra is active) with the current phase on + one line under it, rewritten in place as phases pass. ctrl+s swaps + the line for the full step list; →/← on a step shows/hides its logs + (a live 5-line tail while the step runs). The block stays after + startup — frozen at "Ready!" — as the durable trace. */} + {(infraActivity || sandbox) && ( {' '} - {infraActivity}… ({formatDuration(elapsed)}) + {infraActivity ? `${infraActivity}… (${formatDuration(elapsed)})` : 'Starting sandbox…'} - {/* What the spawn is actually doing: the setup script's latest - output line, streamed from the backend as it runs (a cold - dependency install is most of a slow start). */} - {setupLine && ( + {!sandboxOpen && sandboxPhaseLine(sandbox) && ( {' '} - ⎿ {oneLine(setupLine, 110)} + + ⎿ {oneLine(sandboxPhaseLine(sandbox) as string, 110)} + {inputActive ? ' (ctrl+s: steps)' : ''} + )} + {sandboxOpen && ( + + {(sandbox?.steps.length ?? 0) === 0 && ( + {' '}no setup output yet + )} + {sandbox?.steps.map((step, i) => { + const running = + !sandbox.ready && infraActivity != null && i === sandbox.steps.length - 1 + const cursor = Math.min(stepCursor, sandbox.steps.length - 1) + const selected = i === cursor + const logLines = running + ? step.lines.slice(-RUNNING_TAIL_LINES) + : step.lines.slice(-FINISHED_LOG_LINES) + const hidden = step.lines.length - logLines.length + return ( + + + {' '} + {selected ? '›' : ' '}{' '} + {running ? '✻' : '✓'}{' '} + + {step.label} + {running ? '…' : ''} + {' '} + + ({step.lines.length} log line{step.lines.length === 1 ? '' : 's'}) + + + {selected && stepLogsOpen && ( + + {hidden > 0 && … +{hidden} earlier lines} + {logLines.map((l, j) => ( + + {oneLine(l, 110)} + + ))} + + )} + + ) + })} + {sandbox?.ready && {' '}Ready!} + {' '}↑/↓ step · → logs · ← hide logs · ctrl+s collapse + + )} )} {/* The transcript grows through the middle of the terminal, pinning the @@ -601,6 +698,87 @@ function formatTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) } +// A setup hook name ("image.setup", "post_clone") as a startup phase. +export function hookPhrase(hook: string): string { + switch (hook) { + case 'image.setup': + return 'Building image' + case 'post_clone': + return 'Post-clone setup' + case 'post_start': + return 'Post-start setup' + default: + return hook + } +} + +// A running step's live log tail height, and how much of a finished step's +// log the panel shows before eliding the head with a "+N earlier lines" row. +const RUNNING_TAIL_LINES = 5 +const FINISHED_LOG_LINES = 100 + +export type SandboxStep = { hook: string; label: string; lines: string[] } +export type SandboxState = { steps: SandboxStep[]; ready: boolean } + +// The structural slice of a session record the derivation needs (the SDK's +// SessionRecordWire is not exported from its store entry). +type LifecycleRecordLike = { + feed_seq: number + source: string + record_type: string + payload: Record +} + +// The sandbox startup story from the lifecycle records of the LATEST start: +// one step per setup hook in first-seen order, each accumulating the log +// lines of its chunked sandbox_setup_output records, plus whether the box +// reached ready. sandbox_starting resets everything (a wake tells a fresh +// story); null when no start has been seen. Pure, for tests. +export function deriveSandboxState( + records: readonly LifecycleRecordLike[], + minFeedSeq: number, +): SandboxState | null { + let seen = false + let steps: SandboxStep[] = [] + let ready = false + for (const record of records) { + if (record.feed_seq <= minFeedSeq || record.source !== 'lifecycle') continue + if (record.record_type === 'sandbox_starting') { + seen = true + steps = [] + ready = false + } else if (record.record_type === 'sandbox_setup_output') { + seen = true + const hook = setupOutputHook(record.payload) + let step = steps.find((s) => s.hook === hook) + if (!step) { + step = { hook, label: hookPhrase(hook), lines: [] } + steps.push(step) + } + const lines = Array.isArray(record.payload.lines) + ? record.payload.lines.filter((l): l is string => typeof l === 'string') + : [] + step.lines.push(...lines) + } else if (record.record_type === 'sandbox_ready') { + seen = true + ready = true + } + } + return seen ? { steps, ready } : null +} + +// The collapsed one-liner under the ✻ header: the CURRENT phase only, +// rewritten in place as phases pass — the latest step with its latest log +// line, or "Ready!" once the box is up. Pure, for tests. +export function sandboxPhaseLine(state: SandboxState | null): string | null { + if (!state) return null + if (state.ready) return 'Ready!' + const last = state.steps[state.steps.length - 1] + if (!last) return null + const lastLine = last.lines[last.lines.length - 1] + return lastLine ? `${last.label}… · ${lastLine}` : `${last.label}…` +} + // Long bodies collapse to this many lines until ctrl+r expands them. const COLLAPSE_LINES = 6 diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts new file mode 100644 index 0000000..94b05cf --- /dev/null +++ b/test/connect-app.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { deriveSandboxState, hookPhrase, sandboxPhaseLine } from '../src/ui/ConnectApp' + +let seq = 0 +function rec(recordType: string, payload: Record = {}, source = 'lifecycle') { + return { feed_seq: ++seq, source, record_type: recordType, payload } +} + +describe('deriveSandboxState', () => { + it('returns null before any sandbox lifecycle record', () => { + expect(deriveSandboxState([], 0)).toBeNull() + expect(deriveSandboxState([rec('assistant', {}, 'claude_code')], 0)).toBeNull() + }) + + it('accumulates chunked setup output into one step per hook, in order', () => { + const state = deriveSandboxState( + [ + rec('sandbox_starting'), + rec('sandbox_setup_output', { hook: 'image.setup', chunk: 0, lines: ['a'] }), + rec('sandbox_setup_output', { hook: 'image.setup', chunk: 1, lines: ['b', 'c'] }), + rec('sandbox_setup_output', { hook: 'post_clone', chunk: 0, lines: ['d'] }), + ], + 0, + ) + expect(state).not.toBeNull() + expect(state?.ready).toBe(false) + expect(state?.steps.map((s) => s.hook)).toEqual(['image.setup', 'post_clone']) + expect(state?.steps[0].lines).toEqual(['a', 'b', 'c']) + expect(state?.steps[0].label).toBe('Building image') + expect(state?.steps[1].lines).toEqual(['d']) + }) + + it('marks ready and keeps the steps once sandbox_ready lands', () => { + const state = deriveSandboxState( + [ + rec('sandbox_starting'), + rec('sandbox_setup_output', { hook: 'post_clone', chunk: 0, lines: ['x'] }), + rec('sandbox_ready', { repositories: ['o/r'], cache_tier: 'exact' }), + ], + 0, + ) + expect(state?.ready).toBe(true) + expect(state?.steps).toHaveLength(1) + }) + + it('resets on a new sandbox_starting so a wake tells a fresh story', () => { + const state = deriveSandboxState( + [ + rec('sandbox_starting'), + rec('sandbox_setup_output', { hook: 'image.setup', chunk: 0, lines: ['old'] }), + rec('sandbox_ready', {}), + rec('sandbox_starting'), + rec('sandbox_setup_output', { hook: 'post_start', chunk: 0, lines: ['new'] }), + ], + 0, + ) + expect(state?.ready).toBe(false) + expect(state?.steps.map((s) => s.hook)).toEqual(['post_start']) + expect(state?.steps[0].lines).toEqual(['new']) + }) + + it('ignores records at or below the render cursor (--no-records)', () => { + const starting = rec('sandbox_starting') + const ready = rec('sandbox_ready', {}) + expect(deriveSandboxState([starting, ready], ready.feed_seq)).toBeNull() + }) +}) + +describe('sandboxPhaseLine', () => { + it('is null with no state or no steps yet', () => { + expect(sandboxPhaseLine(null)).toBeNull() + expect(sandboxPhaseLine({ steps: [], ready: false })).toBeNull() + }) + + it('shows only the current phase with its latest log line', () => { + expect( + sandboxPhaseLine({ + steps: [ + { hook: 'image.setup', label: 'Building image', lines: ['a'] }, + { hook: 'post_clone', label: 'Post-clone setup', lines: ['bun install', 'done'] }, + ], + ready: false, + }), + ).toBe('Post-clone setup… · done') + }) + + it('rewrites to Ready! once the box is up', () => { + expect(sandboxPhaseLine({ steps: [], ready: true })).toBe('Ready!') + }) +}) + +describe('hookPhrase', () => { + it('maps known hooks and passes unknown ones through', () => { + expect(hookPhrase('image.setup')).toBe('Building image') + expect(hookPhrase('post_clone')).toBe('Post-clone setup') + expect(hookPhrase('custom.hook')).toBe('custom.hook') + }) +})