Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 0 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 65 additions & 24 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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<string, unknown> | 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<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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).
Expand Down
Loading