From 2c326880737302d19552a1a53fc44af07de4a0b8 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sun, 23 Aug 2026 14:34:45 -0400 Subject: [PATCH] ui: lead the launcher with the prompt and one configuration line The prompt was buried under a who-line and three settings rows, so the first thing on screen was chrome rather than the thing you came to do. It now opens the block, with one summary line under it naming what the next run will use and carrying the way into changing it. The editor takes the session list's place, so the block's height never depends on which is up. Also drop every UI-rendering test: tests here are unit tests, so the launcher's summary logic moved into configSummary() and is tested there. --- CLAUDE.md | 19 ++ bun.lock | 3 - package.json | 1 - src/lib/sessions.ts | 89 +++++-- src/ui/SessionsApp.tsx | 292 +++++++++++++------- test/__snapshots__/screenshot.test.ts.snap | 38 --- test/connect-render.test.ts | 296 --------------------- test/screenshot.test.ts | 145 ---------- test/screenshot.ts | 125 --------- test/scrollback.test.ts | 88 ------ test/sessions.test.ts | 83 ++++-- 11 files changed, 342 insertions(+), 837 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 test/__snapshots__/screenshot.test.ts.snap delete mode 100644 test/connect-render.test.ts delete mode 100644 test/screenshot.test.ts delete mode 100644 test/screenshot.ts delete mode 100644 test/scrollback.test.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1ca3c3c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,19 @@ +# Ellipsis CLI + +## Tests are unit tests only + +Every test in `test/` covers pure functions: input in, value out. Do not add a +test that renders the Ink UI. No fake TTY, no terminal emulator, no snapshot of +a painted screen, no `render()` from ink. + +The UI is verified by running it, not by asserting on frames. When a rendering +claim needs coverage, pull the logic out of the component into a pure function +in `src/lib/` or `src/ui/*Rows.ts` and test that instead. + +`test/screenshot.ts`, `test/screenshot.test.ts`, `test/connect-render.test.ts`, +and `test/scrollback.test.ts` were deleted for this reason. Do not bring them +back. + +## Conventions + +Command naming and `--help` text: see `skills/cli-conventions/SKILL.md`. diff --git a/bun.lock b/bun.lock index a2ec446..b5b7aa6 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,6 @@ "@types/node": "^22.5.0", "@types/react": "^19.2.0", "@types/ws": "^8.5.12", - "@xterm/headless": "^6.0.0", "react-devtools-core": "^6.1.2", "tsup": "^8.3.0", "tsx": "^4.19.0", @@ -172,8 +171,6 @@ "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], - "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], - "acorn": ["acorn@8.17.0", "", { "bin": "bin/acorn" }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], diff --git a/package.json b/package.json index de1f091..b580517 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@types/node": "^22.5.0", "@types/react": "^19.2.0", "@types/ws": "^8.5.12", - "@xterm/headless": "^6.0.0", "react-devtools-core": "^6.1.2", "tsup": "^8.3.0", "tsx": "^4.19.0", diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 62abb9e..6dadf68 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -111,32 +111,14 @@ export function shortAge(iso: string, now: Date = new Date()): string { return `${Math.floor(hours / 24)}d ago` } -// A token count in the tightest readable form: 840 -> "840", 84_200 -> "84.2k", -// 512_000 -> "512k", 1_240_000 -> "1.24M". One decimal only while it buys -// precision, so the column stays narrow. -export function compactTokens(n: number): string { - if (!isFinite(n) || n < 0) return '0' - if (n < 1000) return String(Math.round(n)) - if (n < 1_000_000) { - const k = n / 1000 - return k < 100 ? `${trimZero(k.toFixed(1))}k` : `${Math.round(k)}k` - } - return `${trimZero((n / 1_000_000).toFixed(2))}M` -} - -function trimZero(s: string): string { - return s.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1') -} - -// The nav row's right-hand metadata: how much work the agent did (tokens, -// spend) and when it last moved. Spend is the server's millicent total, the -// same total the chat footer shows. A just-started session drops the empty -// bits rather than showing "0 · $0.00". No source tag: the nav lists cloud -// sessions only, so it would read the same on every row. +// The nav row's right-hand metadata: what the session cost and when it last +// moved. Spend is the server's millicent total, the same total the chat footer +// shows. No token count: it is a number you cannot act on from the launcher. +// A just-started session drops the empty spend rather than showing "$0.00". No +// source tag either: the nav lists cloud sessions only, so it would read the +// same on every row. export function rowMeta(session: AgentSession, now: Date = new Date()): string { const bits: string[] = [] - const tokens = session.tokens?.total ?? 0 - if (tokens > 0) bits.push(compactTokens(tokens)) const millicents = session.cost?.total ?? 0 if (millicents > 0) bits.push(`$${(millicents / 100_000).toFixed(2)}`) bits.push(shortAge(lastEventAt(session), now)) @@ -433,6 +415,65 @@ export interface ComposerChoices { repos: string[] | null } +// What the launcher's one-line configuration summary says the next run will +// use: the model, how many repositories it checks out, and the parts of the +// resolved config a picker can't reach (a custom Dockerfile, environment +// variables). The selected saved config supplies the baseline; the launcher's +// own Model and Repository picks override it, since those are what actually +// ship in the request. +// +// Counts, not names, for the plural bits: three variable names would crowd out +// the model on an 80-column row, and the count is what tells you whether to go +// look. +export function configSummary(input: { + model: string | null + // The explicit checkout set, or null when the row is untouched and the + // server resolves it (in which case the detected repo is what it picks up). + repos: readonly string[] | null + detectedRepo: string | null + // The chosen saved config's parsed YAML, when one is chosen. + agentConfig: Record | null +}): string { + const bits: string[] = [] + const env = readObject(input.agentConfig?.environment) + const model = + input.model ?? readString(readObject(input.agentConfig?.claude)?.model) ?? null + if (model) bits.push(model) + + const configRepos = readArray(env?.repositories)?.length ?? null + const repoCount = + input.repos !== null + ? input.repos.length + : (configRepos ?? (input.detectedRepo ? 1 : null)) + if (repoCount !== null) { + bits.push(repoCount === 1 ? '1 repository' : `${repoCount} repositories`) + } + + const image = readObject(env?.image) + if (readString(image?.dockerfile_append)) bits.push('custom Dockerfile') + + const variables = readArray(env?.variables)?.length ?? 0 + if (variables > 0) { + bits.push(variables === 1 ? '1 environment variable' : `${variables} environment variables`) + } + + return bits.length > 0 ? bits.join(', ') : 'default configuration' +} + +function readObject(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function readArray(value: unknown): unknown[] | null { + return Array.isArray(value) ? value : null +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + // The entry point's base request with the composer's picks layered on: a saved // config as the source, the model + repositories as a per-run config override // (the dashboard composer's shape). diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 5b3a0c0..9d851c5 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -20,6 +20,7 @@ import { composerModelOptions, composerPickerRows, configDisplayName, + configSummary, connectability, type ComposerChoices, type ComposerModel, @@ -41,10 +42,10 @@ import { ConnectApp } from './ConnectApp' // The multi-session UI — what a bare `agent`, `agent "prompt"`, and `agent // session connect ` all open. Two screens, both on the primary buffer: // -// * the LAUNCHER: a compact inline block (~10 rows) — a "connected to" -// line, the Repository / Agent / Model rows, the prompt, and the latest -// sessions underneath. Enter on the prompt starts a session; enter on a -// session row opens its chat. It is short by design, so ink repaints it +// * the LAUNCHER: a compact inline block (~10 rows) — the prompt on the +// first row, one dim settings line under it, the latest sessions, and the +// "connected to" line last. Enter on the prompt starts a session; enter on +// a session row opens its chat. It is short by design, so ink repaints it // in place like any live frame; no alternate screen, no full-height frame. // * the CHAT (ConnectApp) — owns the terminal outright. Its settled // transcript is printed into the terminal's real scrollback, so the @@ -80,8 +81,8 @@ export interface SessionsAppProps { // app.ellipsis.dev base + the customer login, for per-session dashboard links. appBase: string customerLogin: string - // My GitHub login for the launcher's "connected to … as @me in account" - // line; null on an API-key credential, which has no GitHub user behind it. + // My GitHub login for the launcher's closing "@me in account" line; null on + // an API-key credential, which has no GitHub user behind it. ghLogin: string | null // My GitHub account id — the launcher lists sessions attributed to me. null // (e.g. an API-key credential) lists the whole account's sessions. @@ -409,9 +410,11 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { ) } + // Short by design: it is the launcher's last row, so it states who you are + // rather than re-announcing the connection the rest of the block implies. const whoLine = props.ghLogin - ? `connected to ellipsis.dev as @${props.ghLogin} in ${customerLogin}` - : `connected to ellipsis.dev as ${customerLogin}` + ? `@${props.ghLogin} in ${customerLogin}` + : `${customerLogin} (api key)` return ( ( null, ) + // Whether the editor has taken the session list's place. Enter on the summary + // row opens it; esc there closes it. + const [editing, setEditing] = useState(false) // All three pickers deal in the same option shape (ComposerModel), so the // renderer can ask any of them for a group heading or a subtext; only the @@ -694,17 +712,44 @@ function Launcher({ setTextCursor(edited.cursor) return } + if (cursor.kind === 'edit') { + // The summary row: enter (or →) opens the editor under it, esc closes + // it, ↓ moves into whichever screen is showing. + if (key.return || key.rightArrow) { + setEditing(true) + setCursor({ kind: 'option', at: 0 }) + return + } + if (key.upArrow) { + setCursor({ kind: 'prompt' }) + return + } + if (key.downArrow) { + if (editing) setCursor({ kind: 'option', at: 0 }) + else if (!hideList && sessions.length > 0) + setCursor({ kind: 'list', id: sessions[0].id }) + return + } + if (key.escape) { + setEditing(false) + setCursor({ kind: 'prompt' }) + return + } + if (ch && !key.ctrl && !key.meta) toPromptWith(ch) + return + } if (cursor.kind === 'option') { - // The option rows above the prompt, walked vertically: ↑/↓ move - // between them (↑ stops at the first, ↓ off the last returns to the - // prompt), →/enter opens the row's list, typing returns to the prompt. + // The editor's option rows: ↑ off the first climbs back to the summary + // row, ↓ stops at the last (the editor is the bottom screen — the + // session list is not showing), →/enter opens the row's list, esc + // closes the editor, typing returns to the prompt. if (key.upArrow) { - setCursor({ kind: 'option', at: Math.max(0, cursor.at - 1) }) + if (cursor.at <= 0) setCursor({ kind: 'edit' }) + else setCursor({ kind: 'option', at: cursor.at - 1 }) return } if (key.downArrow) { - if (cursor.at >= PICKER_ROWS.length - 1) setCursor({ kind: 'prompt' }) - else setCursor({ kind: 'option', at: cursor.at + 1 }) + if (cursor.at < PICKER_ROWS.length - 1) setCursor({ kind: 'option', at: cursor.at + 1 }) return } if (key.return || key.rightArrow) { @@ -712,17 +757,18 @@ function Launcher({ return } if (key.escape) { - setCursor({ kind: 'prompt' }) + setEditing(false) + setCursor({ kind: 'edit' }) return } if (ch && !key.ctrl && !key.meta) toPromptWith(ch) return } if (cursor.kind === 'list') { - // The session rows below the prompt: ↑/↓ walk them (↑ off the top - // returns to the prompt), enter opens the highlighted session. + // The session rows at the bottom: ↑/↓ walk them (↑ off the top climbs + // back to the summary row), enter opens the highlighted session. if (key.upArrow) { - if (listIdx <= 0) setCursor({ kind: 'prompt' }) + if (listIdx <= 0) setCursor({ kind: 'edit' }) else setCursor({ kind: 'list', id: sessions[listIdx - 1].id }) return } @@ -743,20 +789,17 @@ function Launcher({ if (ch && !key.ctrl && !key.meta) toPromptWith(ch) return } - // At the prompt: ↑ climbs into the option rows above it (landing on the - // nearest, Model), ↓ drops into the session list below. + // At the prompt, the first row: ↓ lands on the summary row beneath it. + // ↑ does nothing — there is nothing above the prompt. if (key.return) { submit() return } - if (key.upArrow) { - setCursor({ kind: 'option', at: PICKER_ROWS.length - 1 }) - return - } if (key.downArrow) { - if (!hideList && sessions.length > 0) setCursor({ kind: 'list', id: sessions[0].id }) + setCursor({ kind: 'edit' }) return } + if (key.upArrow) return if (key.leftArrow) { setTextCursor((c) => Math.max(0, c - 1)) return @@ -783,15 +826,15 @@ function Launcher({ // The summary shown on a row: the single pick's label, or the checked repo // set joined (the detected repo while the list is untouched, "none" once you - // have explicitly unchecked everything — a sandbox with no checkout). + // have explicitly unchecked everything — a sandbox with no checkout). Never + // "loading…": every resting value is known locally, so a pending fetch has + // nothing to do with what this run would use. const rowValue = (key: PickerRow['key']): string => { if (key === 'repo') { - if (repos === null) return 'loading…' if (repoSel === null) return detectedRepo ?? 'Default' if (repoSel.size === 0) return 'none' return [...repoSel].join(', ') } - if (key === 'config' && configs === null) return 'loading…' const options = optionsFor(key) const idx = key === 'config' ? configIdx : modelIdx return options[Math.min(idx, options.length - 1)]?.label ?? 'Default' @@ -799,7 +842,7 @@ function Launcher({ // How many option rows an open dropdown shows: enough to be useful, capped // so the whole launcher still fits a short terminal. - const dropdownCapacity = Math.max(3, Math.min(10, height - (LIST_ROWS + 8))) + const dropdownCapacity = Math.max(3, Math.min(10, height - (LIST_ROWS + 10))) const open = openPicker const openOptions = open ? optionsFor(open.key) : [] const openHover = open ? Math.min(open.hover, openOptions.length - 1) : 0 @@ -852,20 +895,99 @@ function Launcher({ const caretVisible = focused && cursor.kind === 'prompt' && !starting && openPicker === null const listWin = navSlice(sessions.length, LIST_ROWS, listIdx) - // One status line under the list, only when there is something to say: - // otherwise the launcher ends on the session rows. + // The editor and the session list share the space under the summary row, so + // the block's height never depends on which one is showing. + const showList = !hideList && !editing + // What the next run will use, in one line. The selected saved config supplies + // the Dockerfile and variables the pickers can't reach; the local Model and + // Repository picks override its own. + const pickedConfigId = configOptions[Math.min(configIdx, configOptions.length - 1)]?.id ?? null + // The LABEL, not the id: the account-default row names its model while + // carrying the null "server resolves it" id, and the name is what to show. + // Only the synthetic Default row (no server model claims the flag) has + // nothing to name, and there the config's own model answers instead. + const pickedModelLabel = modelOptions[modelIdx]?.label ?? null + const summary = configSummary({ + model: pickedModelLabel === 'Default' ? null : pickedModelLabel, + repos: repoSel === null ? null : [...repoSel], + detectedRepo, + agentConfig: + (configs?.find((c) => c.id === pickedConfigId)?.agent_config as Record< + string, + unknown + > | null) ?? null, + }) + // One status line at the very bottom, only when there is something to say. const statusLine = armed ? CTRL_C_QUIT_HINT : starting ? '✻ Starting session…' - : null + : editing + ? 'esc: back to your sessions' + : null return ( - - {whoLine} - - {PICKER_ROWS.map((r, i) => { + {/* The prompt, the launcher's first row. ONE ❯ on the whole block: the + prompt's gutter carries it only while the prompt holds the cursor — a + blank cell otherwise, exactly like the rows below. Wraps instead of + truncating: a long prompt flows onto the next row rather than running + off the right edge. The explicit width is what ink wraps against; the + key remounts the node so a stale measurement can't misplace the + caret. */} + + {/* The glyph lives in its own fixed gutter so wrapped prompt lines + align under the first typed character, not under the glyph. */} + + + {focused && cursor.kind === 'prompt' && openPicker === null ? SELECTION_GLYPH : ' '} + + + + + {text.slice(0, textCursor)} + {caretVisible && text !== '' && ( + {textCursor < text.length ? text[textCursor] : ' '} + )} + {textCursor < text.length ? text.slice(textCursor + (caretVisible ? 1 : 0)) : ''} + {/* Empty input: the placeholder sits where typed text will land, + its first character carrying the caret (inverse) instead of a + caret cell of its own pushing it a column right. */} + {text === '' && caretVisible && ( + + S + tart a cloud agent… + + )} + {text === '' && !caretVisible && ( + Start a cloud agent… + )} + + + + {/* What the next run will use, and the way into changing it. The bracket + label carries the pressability; the summary after it is plain text. */} + + + + {focused && cursor.kind === 'edit' ? SELECTION_GLYPH : ' '} + + + + + {EDIT_ROW_LABEL} + + {` ${summary}`} + + + {/* The editor, in the session list's place: the three picker rows and + whichever dropdown is open. */} + {editing && } + {editing && PICKER_ROWS.map((r, i) => { const active = focused && openPicker === null && cursor.kind === 'option' && cursor.at === i const isOpen = open?.key === r.key @@ -949,51 +1071,14 @@ function Launcher({ ) })} - {/* The prompt. ONE ❯ on the whole launcher: the prompt's gutter carries - it only while the prompt holds the cursor — a blank cell otherwise, - exactly like the rows above and below. Wraps instead of truncating: a - long prompt flows onto the next row rather than running off the right - edge. The explicit width is what ink wraps against; the key remounts - the node so a stale measurement can't misplace the caret. */} - - {/* The glyph lives in its own fixed gutter so wrapped prompt lines - align under the first typed character, not under the glyph. */} - - - {focused && cursor.kind === 'prompt' && openPicker === null ? SELECTION_GLYPH : ' '} - - - - - {text.slice(0, textCursor)} - {caretVisible && text !== '' && ( - {textCursor < text.length ? text[textCursor] : ' '} - )} - {textCursor < text.length ? text.slice(textCursor + (caretVisible ? 1 : 0)) : ''} - {/* Empty input: the placeholder sits where typed text will land, - its first character carrying the caret (inverse) instead of a - caret cell of its own pushing it a column right. */} - {text === '' && caretVisible && ( - - S - tart a cloud agent… - - )} - {text === '' && !caretVisible && ( - Start a cloud agent… - )} - - - - {/* The latest sessions, under the prompt: status dot + description + a - dim meta tag, in sortSidebarSessions order (status band, newest - first), windowed so the highlight parks two rows from the bottom and - the list scrolls under it. */} - {!hideList && + {/* A blank row splitting what you are about to start from what you have + already run. */} + {showList && } + {/* The latest sessions: status dot + description + a dim meta tag, in + sortSidebarSessions order (status band, newest first), windowed so the + highlight parks two rows from the bottom and the list scrolls under + it. */} + {showList && sessions.slice(listWin.start, listWin.end).map((s) => { const word = rowStatusWord(s) const g = rowGlyph(word) @@ -1030,12 +1115,19 @@ function Launcher({ ) })} - {!hideList && sessions.length === 0 && ( + {showList && sessions.length === 0 && ( {' '} {polledOnce ? 'no sessions yet' : 'loading sessions…'} )} + {/* Who you are, last: true for the whole block and never something you + act on, so it sits below the rows you do act on. */} + + + {' '} + {whoLine} + {/* An API failure gets its own line — a swallowed error is an empty list with no explanation. */} {error && ( diff --git a/test/__snapshots__/screenshot.test.ts.snap b/test/__snapshots__/screenshot.test.ts.snap deleted file mode 100644 index 832551c..0000000 --- a/test/__snapshots__/screenshot.test.ts.snap +++ /dev/null @@ -1,38 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`interactive UI screenshots > a bare \`agent\` opens on the launcher 1`] = ` -"connected to ellipsis.dev as @hunter in acme - Repository: acme/cli - Agent: Default - Model: Default -▶ Start a cloud agent… -● fix the login bug 12.4k · $0.42 · 5m ago -● write release notes 12.4k · $0.42 · 1h ago -● refactor the theme module 12.4k · $0.42 · 5h ago" -`; - -exports[`interactive UI screenshots > enter on a picked session opens its chat 1`] = ` -" - ✦ Cloud agent session on ellipsis.dev - ⎿ Sandbox started · 1 log line - - ● I found the login bug: the token refresh races the redirect. - - ● Fixed in auth.ts; opening a PR now. - - - ▶ - -waiting · $0.42 total · session_a · v1.3.0" -`; - -exports[`interactive UI screenshots > ↓ from the prompt moves the highlight into the session list 1`] = ` -"connected to ellipsis.dev as @hunter in acme - Repository: acme/cli - Agent: Default - Model: Default - Start a cloud agent… -▶ fix the login bug 12.4k · $0.42 · 5m ago -● write release notes 12.4k · $0.42 · 1h ago -● refactor the theme module 12.4k · $0.42 · 5h ago" -`; diff --git a/test/connect-render.test.ts b/test/connect-render.test.ts deleted file mode 100644 index 7711007..0000000 --- a/test/connect-render.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { describe, expect, it } from 'vitest' -import React from 'react' -import { render } from 'ink' -import { PassThrough } from 'node:stream' -import stripAnsi from 'strip-ansi' -import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' -import { SESSION_STREAM_PROTOCOL_VERSION } from '@ellipsis-dev/sdk/stream' -import { ConnectApp } from '../src/ui/ConnectApp' - -// End-to-end render of the real chat against a fake TTY: the scrollback view is -// a claim about what reaches the TERMINAL (settled rows printed once, above a -// repainting live frame), and only an actual render can check it. The stream and -// API are stubbed — nothing here talks to a network. - -const h = React.createElement - -function fakeTty(): { stream: NodeJS.WriteStream; output: () => string } { - const stream = new PassThrough() as unknown as NodeJS.WriteStream - let out = '' - stream.on('data', (chunk: Buffer) => { - out += chunk.toString() - }) - const tty = stream as unknown as { isTTY: boolean; columns: number; rows: number } - tty.isTTY = true - tty.columns = 80 - tty.rows = 24 - return { stream, output: () => out } -} - -// A stdin the app's keyboard handlers will accept: without raw-mode support ink -// throws out of useInput and the app never gets to render its own frame. -function fakeStdin(): NodeJS.ReadStream { - const stdin = new PassThrough() as unknown as NodeJS.ReadStream - const tty = stdin as unknown as { - isTTY: boolean - setRawMode: () => unknown - ref: () => void - unref: () => void - } - tty.isTTY = true - tty.setRawMode = () => stdin - tty.ref = () => {} - tty.unref = () => {} - return stdin -} - -// How many times a string was written to the terminal. The unit of the whole -// scrollback claim: a FLUSHED row is written once and then belongs to the -// terminal, while a live row is rewritten by every repaint. Comparing the two -// counts in one render is what distinguishes them — and it is why each test -// forces a repaint, so "once" means "survived repaints", not "never repainted". -function writes(raw: string, needle: string): number { - return stripAnsi(raw).split(needle).length - 1 -} - -// A cost tick, which changes the footer's total and so forces a real repaint of -// the live region without touching the transcript. (An IDENTICAL frame is -// skipped by ink, so the value has to actually differ.) -function costTick(store: SessionTranscriptStore, cents: number): void { - store.ingest({ - type: 'session', - session: { - id: 'session_render', - status: 'waiting', - cost: { llm: cents, sandbox_cpu: 0, sandbox_memory: 0, fee: 0, total: cents }, - tokens: { - input: 0, - output: 0, - cache_read: 0, - cache_creation: 0, - total: cents, - model: 'claude-fable-5', - }, - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) -} - -const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 40)) - -// The render options every test here uses. `interactive: true` is not optional: -// ink treats CI as non-interactive (is-in-ci), and a non-interactive render -// buffers everything and emits ONE final frame — no erases, no repaints, no -// flush as it happens. Every claim in this file is about the difference -// between a flushed row and a repainted one, so the whole file measures nothing -// under CI without it. The real app pins the same flag for the same reason (see -// runConnect). -const OPTIONS = { patchConsole: false, interactive: true } as const - -let seq = 0 -// A claude_code assistant-message record — the shape recordToItems turns into a -// ● prose row. -function say(text: string): Record { - return { - feed_seq: ++seq, - source: 'claude_code', - record_type: 'event', - payload: { - type: 'assistant', - message: { role: 'assistant', content: [{ type: 'text', text }] }, - }, - } -} - -function lifecycle(recordType: string, payload: Record = {}) { - return { feed_seq: ++seq, source: 'lifecycle', record_type: recordType, payload } -} - -function seededStore(records: Record[], status: string) { - const store = new SessionTranscriptStore() - const session = { - id: 'session_render', - status, - cost: { llm: 0, sandbox_cpu: 0, sandbox_memory: 0, fee: 0, total: 0 }, - tokens: { - input: 0, - output: 0, - cache_read: 0, - cache_creation: 0, - total: 0, - model: 'claude-fable-5', - }, - } - store.ingest({ - type: 'snapshot', - protocol: SESSION_STREAM_PROTOCOL_VERSION, - earliest_feed_seq: null, - session, - messages: [], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - store.ingest({ type: 'records_append', records } as any) - return store -} - -// The app with its network edges stubbed: the socket factory never connects, so -// the render is driven entirely by the seeded store. -function chat(store: SessionTranscriptStore, extra: Record = {}) { - return h(ConnectApp, { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - api: {} as any, - sessionId: 'session_render', - store, - // Never resolves: no stream, no frames, no timers of its own. - openSocket: () => new Promise(() => {}), - canSend: true, - minRenderFeedSeq: 0, - sessionUrl: 'https://app.ellipsis.dev/acme?session=session_render', - model: 'claude-fable-5', - ...extra, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) -} - -describe('ConnectApp — the scrollback view', () => { - it('prints a settled transcript once, and keeps printing it once as the frame repaints', async () => { - // 'waiting' = no turn in flight, so every message is settled and flushes. - const store = seededStore( - [lifecycle('sandbox_ready', {}), say('first message'), say('second message')], - 'waiting', - ) - const { stream, output } = fakeTty() - const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) - await settle() - // Two more repaints of the live region, so "written once" below is a real - // claim about the flush rather than an artifact of a single frame. - // Big enough to move the dollar figure in the footer: $0.00 -> $0.50 -> $1.50. - costTick(store, 50_000) - await settle() - costTick(store, 150_000) - await settle() - app.unmount() - const raw = output() - // The live region demonstrably repainted... - expect(writes(raw, 'total')).toBeGreaterThan(1) - // ...and the settled rows were written once anyway: they are the terminal's - // now, which is what makes the wheel scroll them. - expect(writes(raw, 'first message')).toBe(1) - expect(writes(raw, 'second message')).toBe(1) - }) - - it('holds the last message in the live frame while a turn is in flight', async () => { - // A live turn means the newest message can still grow a tool run under it, so - // it must NOT be flushed — it stays in the repainting region, and the older - // message flushes without it. - const store = seededStore( - [ - lifecycle('sandbox_ready', {}), - say('older message'), - say('newest message'), - lifecycle('turn_started', { turn_id: 't1' }), - ], - 'working', - ) - const { stream, output } = fakeTty() - const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) - await settle() - costTick(store, 50_000) - await settle() - app.unmount() - const raw = output() - // The older message settled and flushed: written once despite the repaint. - expect(writes(raw, 'older message')).toBe(1) - // The newest one is still live — a tool run can still land under it — so it - // is rewritten by each repaint instead. - expect(writes(raw, 'newest message')).toBeGreaterThan(1) - }) - - it('opens with a rule naming the session when it follows another chat', async () => { - const store = seededStore([lifecycle('sandbox_ready', {}), say('hello')], 'waiting') - const { stream, output } = fakeTty() - const app = render(chat(store, { scrollbackBreak: true }), { - stdout: stream, - stdin: fakeStdin(), - ...OPTIONS, - }) - await settle() - app.unmount() - const text = stripAnsi(output()) - expect(text).toContain('session_render') - expect(text).toContain('─') - }) - - it('paints no background on a FLUSHED row, so scrollback carries no stale fill', async () => { - // A row printed into scrollback is never repainted, so a fill on it outlives - // the frame that drew it: stale bands survive a resize or a shorter frame - // with nothing able to clean them up. The composer is the one painted - // surface, and it lives in the live frame — so the assertion is about the - // flushed rows specifically, not about the byte stream as a whole. - const store = seededStore( - [lifecycle('sandbox_ready', {}), say('hello'), say('and again')], - 'waiting', - ) - const { stream, output } = fakeTty() - const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) - await settle() - app.unmount() - // The flushed rows are everything written before the live frame's first - // cursor-hide, which is where ink starts painting the region it owns. - const raw = output() - const flushed = raw.slice(0, raw.indexOf('\u001B[?25l')) - expect(flushed).toContain('hello') - expect(flushed).not.toMatch(/\u001B\[[0-9;]*4[0-7]m/) - expect(flushed).not.toMatch(/\u001B\[[0-9;]*10[0-7]m/) - expect(flushed).not.toContain('48;2;') - expect(flushed).not.toContain('48;5;') - }) - - it('opens the slash-command menu as you type, and completes with tab', async () => { - const store = seededStore([lifecycle('sandbox_ready', {}), say('hi')], 'waiting') - const { stream, output } = fakeTty() - const stdin = fakeStdin() - const app = render(chat(store), { stdout: stream, stdin, ...OPTIONS }) - await settle() - const before = output().length - - // A bare slash offers every command, with its description. - stdin.write('/') - await settle() - let frame = stripAnsi(output().slice(before)) - expect(frame).toContain('/stop') - expect(frame).toContain('/sessions') - expect(frame).toContain('interrupt the agent') - - // Typing narrows it to one. Measured from HERE, not from the start: the byte - // stream keeps every earlier frame, so a cumulative slice would still hold - // the full list printed a moment ago. - const beforeNarrow = output().length - stdin.write('se') - await settle() - frame = stripAnsi(output().slice(beforeNarrow)) - expect(frame).toContain('/sessions') - expect(frame).not.toContain('/stop') - - // Tab completes the highlighted command into the input. - const beforeTab = output().length - stdin.write('\t') - await settle() - expect(stripAnsi(output().slice(beforeTab))).toContain('/sessions') - app.unmount() - }) - - it('does not capture the mouse in the chat, so the terminal keeps the wheel', async () => { - // The point of the whole exercise: no SGR mouse reporting (1000h/1006h) is - // armed while the chat is the view, which is what leaves wheel scrolling and - // select/copy to the terminal. - const store = seededStore([lifecycle('sandbox_ready', {}), say('hello')], 'waiting') - const { stream, output } = fakeTty() - const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) - await settle() - app.unmount() - expect(output()).not.toContain('[?1000h') - expect(output()).not.toContain('[?1049h') - }) -}) diff --git a/test/screenshot.test.ts b/test/screenshot.test.ts deleted file mode 100644 index 5c5cd23..0000000 --- a/test/screenshot.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it } from 'vitest' -import React from 'react' -import { SESSION_BAR_DEFAULTS } from '../src/lib/config' -import { SessionsApp } from '../src/ui/SessionsApp' -import type { AgentSession } from '../src/lib/types' -import { launchPage } from './screenshot' - -// Render tests of the interactive UI, driven through the harness in -// screenshot.ts: assertions run on the emulated screen's text. All network -// edges are stubbed. - -const h = React.createElement - -function stubSession(id: string, prompt: string, minutesAgo: number): AgentSession { - const at = new Date(Date.now() - minutesAgo * 60_000).toISOString() - return { - id, - status: 'waiting', - prompt, - source: 'cli', - created_at: at, - updated_at: at, - last_activity_at: at, - prompting: { enabled: true }, - agent: { config: { ellipsis: { name: null } }, config_id: null, override: null, source: 'platform_default' }, - tokens: { total: 12_400 }, - cost: { total: 42_000 }, - } as unknown as AgentSession -} - -const SESSIONS = [ - stubSession('session_a', 'fix the login bug', 5), - stubSession('session_b', 'write release notes', 60), - stubSession('session_c', 'refactor the theme module', 300), -] - -// A claude_code assistant-message record — what the chat renders as a ● prose -// row (same shape as connect-render.test.ts). -let seq = 0 -function say(text: string): Record { - return { - feed_seq: ++seq, - source: 'claude_code', - record_type: 'event', - payload: { - type: 'assistant', - message: { role: 'assistant', content: [{ type: 'text', text }] }, - }, - } -} - -const RECORDS: Record[]> = { - session_a: [ - { feed_seq: ++seq, source: 'lifecycle', record_type: 'sandbox_ready', payload: {} }, - say('I found the login bug: the token refresh races the redirect.'), - say('Fixed in auth.ts; opening a PR now.'), - ], -} - -// SessionsApp's whole API surface for these screens: the session-list poll, -// the launcher's three picker fetches, and the chat's session + records load. -// Empty picker lists are a real state (the launcher falls back to its -// built-ins). -const api = { - sessions: { - list: async () => ({ items: SESSIONS }), - get: async (id: string) => ({ session: SESSIONS.find((s) => s.id === id) }), - records: async (id: string) => ({ - response: { records: RECORDS[id] ?? [], earliest_feed_seq: null, messages: [] }, - }), - }, - agents: { configs: { list: async () => ({ configs: [] }) } }, - integrations: { github: { repos: async () => ({ repositories: [] }) } }, - models: { list: async () => ({ models: [] }) }, -} - -function app() { - return h(SessionsApp, { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - api: api as any, - openSocket: () => new Promise(() => {}), - appBase: 'https://app.ellipsis.dev', - customerLogin: 'acme', - ghLogin: 'hunter', - authorId: 1, - detectedRepo: 'acme/cli', - sessionBar: { ...SESSION_BAR_DEFAULTS }, - buildStartRequest: (prompt: string) => (prompt ? { prompt } : { idle_start: true }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) -} - -describe('interactive UI screenshots', () => { - it('a bare `agent` opens on the launcher', async () => { - const page = await launchPage(app()) - const screen = page.text() - expect(screen).toContain('connected to ellipsis.dev as @hunter in acme') - // The option rows: the detected repo as the Repository row's resting value. - expect(screen).toContain('Repository: acme/cli') - expect(screen).toContain('Agent: Default') - expect(screen).toContain('Model:') - // The latest sessions, under the prompt. - for (const s of SESSIONS) expect(screen).toContain(s.prompt as string) - expect(screen).toMatchSnapshot() - page.unmount() - }) - - it('↓ from the prompt moves the highlight into the session list', async () => { - const page = await launchPage(app()) - await page.press('down') - const moved = page.text() - // The ▶ selection glyph left the prompt and sits on the newest session - // (sort is status band, then newest first). - const cursorLine = moved.split('\n').find((l) => l.includes('▶')) - expect(cursorLine).toBeDefined() - expect(cursorLine).toContain('fix the login bug') - expect(moved).toMatchSnapshot() - page.unmount() - }) - - it('enter on a picked session opens its chat', async () => { - const page = await launchPage(app()) - await page.press('down') - await page.press('enter') - const chat = page.text() - // The launcher gave way to session_a's chat: its transcript printed into - // the primary buffer, with the composer underneath. - expect(chat).toContain('the token refresh races the redirect') - expect(chat).toContain('opening a PR now') - expect(chat).toContain('session_a') - expect(chat).toMatchSnapshot() - page.unmount() - }) - - it('esc in the chat returns to the launcher', async () => { - const page = await launchPage(app()) - await page.press('down') - await page.press('enter') - await page.press('escape') - const back = page.text() - expect(back).toContain('connected to ellipsis.dev as @hunter in acme') - expect(back).toContain('Repository: acme/cli') - page.unmount() - }) -}) diff --git a/test/screenshot.ts b/test/screenshot.ts deleted file mode 100644 index e92856c..0000000 --- a/test/screenshot.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { render } from 'ink' -import type { ReactElement } from 'react' -import { PassThrough } from 'node:stream' -import { Terminal } from '@xterm/headless' - -// The interactive-UI render harness. An ink app renders against a fake -// TTY exactly as in connect-render.test.ts, but every byte it writes is fed -// through a REAL terminal emulator (@xterm/headless), so cursor moves and -// repaints resolve into the final screen a user would see — not a stream of -// ANSI. Tests assert on `page.text()` (the grid as plain text, diffable in -// CI). Fully offline: no PTY, no network. - -// Named keys → the bytes a terminal sends for them. Anything not named is -// written verbatim (so page.press('n') just types n). -const KEYS: Record = { - up: '\x1b[A', - down: '\x1b[B', - right: '\x1b[C', - left: '\x1b[D', - enter: '\r', - escape: '\x1b', - tab: '\t', - backspace: '\x7f', -} - -export interface Page { - // Press a named key (or type a literal string), then settle. - press(key: string): Promise - // Type a string into the app, then settle. - type(text: string): Promise - // Change the fake window's size: the emulator and ink both hear about it. - resize(cols: number, rows: number): Promise - // Wait for timers + the emulator to drain, so the grid is current. - settle(): Promise - // The visible screen as plain text, one line per row, right-trimmed. - text(): string - unmount(): void -} - -export async function launchPage( - element: ReactElement, - { cols = 80, rows = 24 }: { cols?: number; rows?: number } = {}, -): Promise { - // allowProposedApi: reading the buffer's cells (text()) is - // xterm's "proposed" API surface. - // convertEol: a real TTY's driver turns \n into \r\n (ONLCR); ink relies on - // that, and without it every line starts where the previous one ended. - const term = new Terminal({ cols, rows, allowProposedApi: true, convertEol: true }) - - // Writes into the emulator complete asynchronously; every chunk's callback - // lands on this chain, so awaiting it means "the grid reflects everything - // ink has written so far". - let drained: Promise = Promise.resolve() - const feed = (data: string): void => { - drained = drained.then(() => new Promise((resolve) => term.write(data, resolve))) - } - - const stdout = new PassThrough() as unknown as NodeJS.WriteStream - stdout.on('data', (chunk: Buffer) => feed(chunk.toString())) - const outTty = stdout as unknown as { isTTY: boolean; columns: number; rows: number } - outTty.isTTY = true - outTty.columns = cols - outTty.rows = rows - - // A stdin the app's useInput accepts: without raw-mode support ink throws - // out of useInput before the app renders a frame of its own. - const stdin = new PassThrough() as unknown as NodeJS.ReadStream - const inTty = stdin as unknown as { - isTTY: boolean - setRawMode: () => unknown - ref: () => void - unref: () => void - } - inTty.isTTY = true - inTty.setRawMode = () => stdin - inTty.ref = () => {} - inTty.unref = () => {} - - // interactive pinned on: ink treats CI as non-interactive and would buffer - // one final frame — no repaints, nothing this harness is - // for (same reason connect-render.test.ts pins it). - const app = render(element, { stdout, stdin, patchConsole: false, interactive: true }) - - const settle = async (): Promise => { - // Two beats: effects that queue work behind a resolved promise (seeded - // fetch stubs) need a second turn of the loop. - await new Promise((resolve) => setTimeout(resolve, 60)) - await new Promise((resolve) => setTimeout(resolve, 60)) - await drained - } - - const page: Page = { - async press(key) { - stdin.write(KEYS[key] ?? key) - await settle() - }, - async type(text) { - stdin.write(text) - await settle() - }, - async resize(nextCols, nextRows) { - outTty.columns = nextCols - outTty.rows = nextRows - term.resize(nextCols, nextRows) - ;(stdout as unknown as NodeJS.EventEmitter).emit('resize') - await settle() - }, - settle, - text() { - const buffer = term.buffer.active - const lines: string[] = [] - for (let y = 0; y < term.rows; y++) { - lines.push(buffer.getLine(buffer.viewportY + y)?.translateToString(true) ?? '') - } - return lines.join('\n').replace(/\s+$/, '') - }, - unmount() { - app.unmount() - term.dispose() - }, - } - - await settle() - return page -} diff --git a/test/scrollback.test.ts b/test/scrollback.test.ts deleted file mode 100644 index e5995d8..0000000 --- a/test/scrollback.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from 'vitest' -import React, { useEffect, useState } from 'react' -import { Box, Static, Text, render } from 'ink' -import { PassThrough } from 'node:stream' -import stripAnsi from 'strip-ansi' - -// Offline render harness for the terminal mechanism the scrollback view rests -// on: ink's flush. It is not observable in the React tree — the -// difference is in the BYTES written — so it is driven against a fake TTY -// stream and asserted on its output. -// createElement rather than JSX: the suite is .ts by convention. -const h = React.createElement - -// A stdout ink will treat as an interactive terminal, recording what is written. -function fakeTty(): { stream: NodeJS.WriteStream; output: () => string } { - const stream = new PassThrough() as unknown as NodeJS.WriteStream - let out = '' - stream.on('data', (chunk: Buffer) => { - out += chunk.toString() - }) - const tty = stream as unknown as { isTTY: boolean; columns: number; rows: number } - tty.isTTY = true - tty.columns = 80 - tty.rows = 24 - return { stream, output: () => out } -} - -const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 25)) - -// `interactive: true` is not optional: ink treats CI as non-interactive -// (is-in-ci), and a non-interactive render buffers everything into ONE final -// frame — no erases, no repaints, no flush as it happens. These tests -// are about exactly that difference, so they measure nothing under CI without it. -const OPTIONS = { patchConsole: false, interactive: true } as const - -describe(' flush — the scrollback view', () => { - it('prints each settled row ONCE and never reprints it', async () => { - // The invariant the whole scrollback view rests on: a flushed row is printed - // and then belongs to the terminal. Reprinting shows up as a duplicated - // transcript, which is what makes this worth pinning down. - const { stream, output } = fakeTty() - function App(): React.ReactElement { - const [rows, setRows] = useState(['alpha']) - useEffect(() => { - // A second row settles, exactly as a second message would. - const t = setTimeout(() => setRows(['alpha', 'bravo']), 5) - return () => clearTimeout(t) - }, []) - return h( - Box, - { flexDirection: 'column' }, - h(Static, { items: rows }, (row: string) => h(Text, { key: row }, row)), - h(Text, null, 'live'), - ) - } - const app = render(h(App), { stdout: stream, ...OPTIONS }) - await settle() - app.unmount() - const text = stripAnsi(output()) - expect(text.match(/alpha/g)?.length).toBe(1) - expect(text.match(/bravo/g)?.length).toBe(1) - }) - - it('reprints from the start when the item list SHRINKS', async () => { - // Why ConnectApp holds flushed rows in an append-only ref instead of - // re-deriving them each frame: re-syncs its printed count from - // items.length, so a shorter list (what withholding the flush for the alt - // screen would produce) makes the next full list reprint what was already on - // screen. - const { stream, output } = fakeTty() - function App(): React.ReactElement { - const [rows, setRows] = useState(['alpha']) - useEffect(() => { - const shrink = setTimeout(() => setRows([]), 5) - const grow = setTimeout(() => setRows(['alpha']), 15) - return () => { - clearTimeout(shrink) - clearTimeout(grow) - } - }, []) - return h(Static, { items: rows }, (row: string) => h(Text, { key: row }, row)) - } - const app = render(h(App), { stdout: stream, ...OPTIONS }) - await settle() - app.unmount() - expect(stripAnsi(output()).match(/alpha/g)?.length).toBe(2) - }) -}) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index fad5ce4..5ff02d7 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from 'vitest' import { applyComposerChoices, attentionFlip, - compactTokens, COMPOSER_MODELS, composerModelOptions, composerPickerRows, + configSummary, connectability, modelRate, rateDollars, @@ -156,37 +156,86 @@ describe('lastEventAt / shortAge', () => { }) }) -describe('compactTokens', () => { - it('scales the unit and drops noise decimals', () => { - expect(compactTokens(0)).toBe('0') - expect(compactTokens(840)).toBe('840') - expect(compactTokens(4800)).toBe('4.8k') - expect(compactTokens(84_200)).toBe('84.2k') - expect(compactTokens(12_000)).toBe('12k') - expect(compactTokens(512_000)).toBe('512k') - expect(compactTokens(1_240_000)).toBe('1.24M') - expect(compactTokens(2_000_000)).toBe('2M') - }) -}) - describe('rowMeta', () => { const now = new Date('2026-07-23T12:00:00Z') - it('reads tokens, spend, and age', () => { + it('reads spend and age, never the token count', () => { const s = session({ tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0, total: 84_200, model: '' }, cost: { llm: 30_000, sandbox_cpu: 10_000, sandbox_memory: 2_000, fee: 0, total: 42_000 }, updated_at: '2026-07-23T11:58:00Z', } as never) - expect(rowMeta(s, now)).toBe('84.2k · $0.42 · 2m ago') + expect(rowMeta(s, now)).toBe('$0.42 · 2m ago') }) - it('drops the work bits a fresh session has none of', () => { + it('drops the spend a fresh session has none of', () => { const s = session({ updated_at: '2026-07-23T11:59:48Z' }) expect(rowMeta(s, now)).toBe('12s ago') }) }) +describe('configSummary', () => { + it('reads the model and the cwd repo with no config selected', () => { + expect( + configSummary({ + model: 'claude-opus-5', + repos: null, + detectedRepo: 'acme/cli', + agentConfig: null, + }), + ).toBe('claude-opus-5, 1 repository') + }) + + it('names every part of a selected config the pickers cannot reach', () => { + expect( + configSummary({ + model: null, + repos: null, + detectedRepo: 'acme/cli', + agentConfig: { + claude: { model: 'claude-sonnet-5' }, + environment: { + repositories: [{ owner: 'acme', name: 'cli' }, { owner: 'acme', name: 'api' }], + image: { dockerfile_append: 'RUN apt-get install -y jq' }, + variables: [{ name: 'A' }, { name: 'B' }, { name: 'C' }], + }, + }, + }), + ).toBe('claude-sonnet-5, 2 repositories, custom Dockerfile, 3 environment variables') + }) + + it('lets the local picks override the config they came from', () => { + expect( + configSummary({ + model: 'claude-haiku-4-5-20251001', + repos: [], + detectedRepo: 'acme/cli', + agentConfig: { + claude: { model: 'claude-sonnet-5' }, + environment: { repositories: [{ owner: 'acme', name: 'cli' }] }, + }, + }), + ).toBe('claude-haiku-4-5-20251001, 0 repositories') + }) + + it('says so when nothing is resolved yet', () => { + expect( + configSummary({ model: null, repos: null, detectedRepo: null, agentConfig: null }), + ).toBe('default configuration') + }) + + it('ignores a blank dockerfile and an empty variable list', () => { + expect( + configSummary({ + model: 'claude-opus-5', + repos: null, + detectedRepo: null, + agentConfig: { environment: { image: { dockerfile_append: ' ' }, variables: [] } }, + }), + ).toBe('claude-opus-5') + }) +}) + describe('sessionBarQuery', () => { const bar = { days: 7,