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,