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
4 changes: 2 additions & 2 deletions bun.lock

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ellipsis-dev/sdk": "^0.13.0",
"@ellipsis-dev/sdk": "^0.15.0",
"chalk": "^5.6.2",
"cli-table3": "^0.6.5",
"commander": "^12.1.0",
Expand Down
11 changes: 6 additions & 5 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,10 +311,11 @@ function manufacturerRank(manufacturer: ModelManufacturer | string): number {
return at === -1 ? MANUFACTURER_ORDER.length : at
}

// Rate-card cents per 1M tokens → "$5", "$0.75". Whole dollars drop the
// Rate-card millicents per 1M tokens → "$5", "$0.75". Whole dollars drop the
// ".00": at a glance "$5" is a price, where "$5.00" reads as a table cell.
export function rateDollars(cents: number): string {
return cents % 100 === 0 ? `$${cents / 100}` : `$${(cents / 100).toFixed(2)}`
export function rateDollars(millicents: number): string {
const dollars = millicents / 100_000
return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`
}

// A model's price as two table cells: the two lanes that decide what a session
Expand All @@ -328,8 +329,8 @@ export function modelRate(
): { input: string; output: string } | null {
if (!rate) return null
return {
input: rateDollars(rate.input_cents_per_1m_tokens),
output: rateDollars(rate.output_cents_per_1m_tokens),
input: rateDollars(rate.input_millicents_per_1m_tokens),
output: rateDollars(rate.output_millicents_per_1m_tokens),
}
}

Expand Down
27 changes: 17 additions & 10 deletions src/lib/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,19 @@ interface StepContentBlock {
content?: unknown
}

// The payload as a loose bag. Every read below is best-effort display text
// across three harness formats (claude_sdk@1, codex_jsonl@1,
// ellipsis_lifecycle@1), so narrowing the SDK's per-format union at each field
// would buy nothing a `typeof` guard doesn't already give.
function fields(record: SessionRecord): Record<string, unknown> {
return record.payload as Record<string, unknown>
}

// One session_record as a single display line: index, timestamp, record type,
// and the first ~120 characters of its text content. Exported for tests.
export function formatStepLine(record: SessionRecord): string {
const subtype =
typeof record.payload.subtype === 'string' ? record.payload.subtype : null
const raw = fields(record).subtype
const subtype = typeof raw === 'string' ? raw : null
const type = subtype ? `${record.record_type}/${subtype}` : record.record_type
return [
String(record.stream_seq).padStart(4),
Expand All @@ -44,18 +52,17 @@ export function formatStepLine(record: SessionRecord): string {
}

// Best-effort display text for a stored record. A lifecycle record shows its
// notification line; a claude_code record's `payload` is the raw Claude Code
// stream event — a result step carries `result`, assistant/user steps carry an
// API message whose content is a string or a list of blocks (text, thinking,
// tool_use, tool_result). Anything unrecognized falls back to its JSON.
// notification line; a claude_code record's `payload` is the raw agent stream
// event — a result step carries `result`, assistant/user steps carry `content`,
// a string or a list of blocks (text, thinking, tool_use, tool_result).
// Anything unrecognized falls back to its JSON.
export function recordText(record: SessionRecord): string {
const data = fields(record)
if (record.source === 'lifecycle') {
return lifecycleText(record.record_type, record.payload) ?? record.record_type
return lifecycleText(record.record_type, data) ?? record.record_type
}
const data = record.payload ?? {}
if (typeof data.result === 'string') return data.result
const message = data.message as { content?: unknown } | undefined
const text = contentText(message?.content)
const text = contentText(data.content)
if (text) return text
return JSON.stringify(data)
}
Expand Down
83 changes: 59 additions & 24 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,12 @@ import {
sandboxOutputStep,
sandboxPhaseLabel,
statusActivityText,
type CCEvent,
type SessionTranscriptStore,
type TranscriptItem,
} from '@ellipsis-dev/sdk/store'
import { lifecycleText } from '../lib/steps'
import { errorDetail } from '../lib/api'
import type { Ellipsis } from '@ellipsis-dev/sdk'
import type { Ellipsis, SdkRecord, SessionRecord } from '@ellipsis-dev/sdk'
import { hyperlink } from '../lib/urls'
import { usdNumberFromMillicents } from '../lib/output'
import { applyEditShortcut } from '../lib/editing'
Expand Down Expand Up @@ -258,6 +257,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {

const [elapsed, setElapsed] = useState(0)
const [notice, setNotice] = useState<string | null>(props.initialNotice ?? null)
// How many undisplayable records you've already been told about, so the
// footer warning clears on your next send and only returns if MORE arrive.
const [undisplayedSeen, setUndisplayedSeen] = useState(0)
// The slash-command menu's highlighted index. The menu itself is derived from
// what is typed (see `menu` below) rather than stored — it is open exactly when
// the line starts with `/` and something still matches — so this is the only
Expand Down Expand Up @@ -303,8 +305,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
// one-line progress block up top (sandboxProgress), not as transcript rows.
// Each turn's closing duration/cost summary is dropped too (see
// reshapeTranscript) — the footer carries the session's spend.
const { items } = useMemo(() => {
const shaped = reshapeTranscript(snapshot.records, props.minRenderFeedSeq)
const { items, undisplayed } = useMemo(() => {
const shaped = reshapeTranscript(slice(snapshot.records), props.minRenderFeedSeq)
// Client-side notes land at the end: they describe what you just did, so
// they belong under everything the server has sent so far.
for (const note of chatNotes) {
Expand All @@ -322,7 +324,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
foldCosts(
snapshot.records
.filter((r) => r.source === 'claude_code')
.map((r) => r.payload as CCEvent),
.map((r) => r.payload as SdkRecord),
),
[snapshot.records],
)
Expand All @@ -340,7 +342,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
// as the durable trace (the sandbox_ready transcript notice is suppressed
// below in its favour).
const sandbox = useMemo(
() => deriveSandboxState(snapshot.records, props.minRenderFeedSeq),
() => deriveSandboxState(slice(snapshot.records), props.minRenderFeedSeq),
[snapshot.records, props.minRenderFeedSeq],
)
// Bodies of the server's PENDING inbox messages — the durable queued signal.
Expand Down Expand Up @@ -490,13 +492,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
// Which silence a started-but-quiet turn is in ('boot': Claude Code is
// still starting in the sandbox; 'turn': the warm agent between records),
// for the fallback live line's label.
const awaitingAgent = useMemo(() => awaitingAgentPhase(snapshot.records), [snapshot.records])
const awaitingAgent = useMemo(() => awaitingAgentPhase(slice(snapshot.records)), [snapshot.records])

// Sends the agent took mid-gap: delivered to the agent but its user-echo
// transcript record hasn't landed yet (the echo can lag by a whole sandbox
// wake). Rendered as full-colour user rows until the echo replaces them.
const acceptedSends = useMemo(
() => deliveredUnechoedSends(snapshot.records),
() => deliveredUnechoedSends(slice(snapshot.records)),
[snapshot.records],
)

Expand Down Expand Up @@ -580,6 +582,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
const text = raw.trim()
setComposer({ text: '', cursor: 0 })
if (!text) return
setUndisplayedSeen(undisplayed)
// A leading slash claims the line for the CLI. An unknown one is REFUSED,
// not forwarded: a typo'd command sent on as prose is a message you did
// not mean to send, and the agent cannot tell it from one you did.
Expand Down Expand Up @@ -644,7 +647,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
}
})()
},
[api, exit, pump, sessionId, props.onFocusNav],
[api, exit, pump, sessionId, undisplayed, props.onFocusNav],
)

// The composer renders whenever sending is possible.
Expand Down Expand Up @@ -1099,12 +1102,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
const linked = metaParts(hyperlink(props.sessionUrl, sessionId))
.slice(0, kept)
.join(META_SEP)
const body = ctrlCArmed ? CTRL_C_QUIT_HINT : plain
// Records that arrived but rendered nothing take the whole line: a shape this
// build can't read means the transcript is lying about what was said, which
// outranks the spend and the session id. It holds the line until the next
// send, and a further drop brings it back with the new count.
const undisplayedNew = Math.max(0, undisplayed - undisplayedSeen)
const dropWarning =
undisplayedNew > 0
? `${undisplayedNew} ${undisplayedNew === 1 ? 'event' : 'events'} could not be displayed, this CLI may be out of date`
: null
const body = ctrlCArmed ? CTRL_C_QUIT_HINT : (dropWarning ?? plain)
const pad = Math.max(0, Math.floor((cols - body.length) / 2))
// Ink measures the hyperlink's invisible URL bytes as width, so the link
// only ships when padding plus escape bytes still fit the row; the padding
// gives way first, then the link itself.
const linkedPad = ctrlCArmed ? -1 : Math.min(pad, cols - 1 - linked.length)
const linkedPad =
ctrlCArmed || dropWarning ? -1 : Math.min(pad, cols - 1 - linked.length)
const metaLine =
linkedPad >= 0
? `${' '.repeat(linkedPad)}${linked}`
Expand Down Expand Up @@ -1253,7 +1266,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
</Text>
</Box>
)}
<Text color={theme.muted}>{metaLine}</Text>
<Text color={dropWarning && !ctrlCArmed ? theme.error : theme.muted}>{metaLine}</Text>
</Box>
</Box>
)
Expand Down Expand Up @@ -1523,8 +1536,11 @@ export type SandboxState = {
log: SandboxLogLine[]
}

// The structural slice of a session record the derivations need (the SDK's
// SessionRecordWire is not exported from its store entry).
// The structural slice of a session record the derivations below need. The SDK
// types each harness's payload as its own union (claude_sdk@1, codex_jsonl@1,
// ellipsis_lifecycle@1) with no index signature, and these functions only ever
// read display fields by name across all three — so they take the slice, and
// the cast happens once, in `slice` below.
type LifecycleRecordLike = {
feed_seq: number
source: string
Expand All @@ -1534,6 +1550,10 @@ type LifecycleRecordLike = {
session_message_id?: string | null
}

function slice(records: readonly SessionRecord[]): readonly LifecycleRecordLike[] {
return records as readonly LifecycleRecordLike[]
}

// The chat is a LOG of the session: what was said, and what happened to the
// session while it was being said. So the milestones — it went to sleep, it is
// waking again, it was cancelled — land in the transcript, in feed order,
Expand All @@ -1545,11 +1565,17 @@ type LifecycleRecordLike = {
// bookkeeping, not conversation — the footer's running spend is where that
// story lives. An error summary survives as its own (red) line under a plain
// label: a failed turn is content. Pure, for tests.
// `undisplayed` counts agent records that arrived and rendered NOTHING — the
// signal for a payload shape this build cannot read (a harness change on the
// server, an out-of-date CLI). Without it such a record is invisible twice
// over: no row, and no hint that a row is missing. Init events are excluded:
// they are deliberately silent here.
export function reshapeTranscript(
records: readonly LifecycleRecordLike[],
minRenderFeedSeq: number,
): { items: TranscriptItem[] } {
): { items: TranscriptItem[]; undisplayed: number } {
const items: TranscriptItem[] = []
let undisplayed = 0
// Index of the "Waking the session…" line still awaiting its outcome, so the
// resumed record can settle it in place instead of adding a second row. The
// line KEEPS ITS KEY, so settling it doesn't move the scroll anchor or the
Expand All @@ -1570,22 +1596,31 @@ export function reshapeTranscript(
}
continue
}
const isResult = r.source === 'claude_code' && r.payload.type === 'result'
// recordToItems reads only the structural slice (source, record_type,
// payload); its SessionRecordWire param type isn't exported from the
// SDK's store entry, hence the cast.
for (const item of recordToItems(
r as Parameters<typeof recordToItems>[0],
`s${r.feed_seq}`,
)) {
const isResult = r.source === 'claude_code' && r.payload.kind === 'result'
// recordToItems reads the structural slice plus record_format, which it
// switches on; its typed per-harness param isn't the slice these
// derivations pass, hence the cast. It returns undefined for a
// record_format this SDK build has no reader for — a new harness against an
// old CLI — which counts as undisplayed just like an unreadable payload.
// A record the reader THROWS on (an unrecognized payload kind reaches
// blocksOf(undefined) inside the SDK) must cost one row, not the whole
// transcript: the render is the last place that should die of a wire change.
let rendered: TranscriptItem[]
try {
rendered = recordToItems(r as Parameters<typeof recordToItems>[0], `s${r.feed_seq}`) ?? []
} catch {
rendered = []
}
if (rendered.length === 0 && r.record_type !== 'system') undisplayed++

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The undisplayed count treats records the SDK reader is silent on by design as drops, so healthy sessions get the "this CLI may be out of date" footer; only record_type === 'system' is excluded, but eventToItems also returns [] for kind: 'rate_limit' (part of the persisted claude_sdk@1 union) and codexEventToItems returns [] for every codex frame except item.completed/error/turn.failed.

Verified against the installed 0.15.0 by calling reshapeTranscript directly: a rate_limit record yields {items: [], undisplayed: 1}, and a normal six-record codex turn (thread.started, turn.started, item.started, item.updated, item.completed, turn.completed) yields one rendered row and undisplayed: 5. Since agent session connect does not filter by harness and the platform persists every Codex exec --json ThreadEvent verbatim (source=codex, codex_jsonl@1), connecting to a codex session shows e.g. "37 events could not be displayed, this CLI may be out of date" while rendering the transcript correctly — and because the warning takes the whole meta line and only clears on a send, a watch-only or closed session (canSend false, --no-input) loses status/spend/session id for the rest of the run.

Suggested change
if (rendered.length === 0 && r.record_type !== 'system') undisplayed++
// Silent BY DESIGN is not a drop: claude system/rate_limit rows, and the
// codex frames that carry no row of their own (thread/turn/item.started,
// item.updated, turn.completed).
const silentByDesign =
r.source === 'codex'
? r.record_type !== 'item.completed' && r.record_type !== 'error'
: r.record_type === 'system' || r.record_type === 'rate_limit'
if (rendered.length === 0 && !silentByDesign) undisplayed++

for (const item of rendered) {
if (item.kind === 'summary' && isResult) {
if (item.isError) items.push({ ...item, text: 'turn ended with an error' })
continue
}
items.push(item)
}
}
return { items }
return { items, undisplayed }
}

// The session milestones worth a line in the chat log, and how each reads.
Expand Down
50 changes: 43 additions & 7 deletions test/connect-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,17 @@ import { theme } from '../src/lib/theme'
import type { TranscriptItem } from '@ellipsis-dev/sdk/store'

let seq = 0
// `record_format` is what recordToItems switches on, so a fixture carries the
// same token the wire does: claude_sdk@1 for agent records, ellipsis_lifecycle@1
// for platform ones.
function rec(recordType: string, payload: Record<string, unknown> = {}, source = 'lifecycle') {
return { feed_seq: ++seq, source, record_type: recordType, payload }
return {
feed_seq: ++seq,
source,
record_type: recordType,
record_format: source === 'lifecycle' ? 'ellipsis_lifecycle@1' : 'claude_sdk@1',
payload,
}
}

describe('deriveSandboxState', () => {
Expand Down Expand Up @@ -422,11 +431,11 @@ describe('deliveredUnechoedSends', () => {

describe('reshapeTranscript', () => {
const assistant = (text: string) =>
rec('cc', { type: 'assistant', message: { content: [{ type: 'text', text }] } }, 'claude_code')
rec('cc', { kind: 'assistant', content: [{ type: 'text', text }] }, 'claude_code')
const result = (over: Record<string, unknown> = {}) =>
rec(
'cc',
{ type: 'result', duration_ms: 4000, total_cost_usd: 0.1, is_error: false, ...over },
{ kind: 'result', duration_ms: 4000, cost_usd: 0.1, is_error: false, ...over },
'claude_code',
)

Expand All @@ -439,9 +448,9 @@ describe('reshapeTranscript', () => {
const { items } = reshapeTranscript(
[
assistant('one'),
result({ total_cost_usd: 0.1 }),
result({ cost_usd: 0.1 }),
assistant('two'),
result({ total_cost_usd: 0.25, duration_ms: 2000 }),
result({ cost_usd: 0.25, duration_ms: 2000 }),
],
0,
)
Expand All @@ -453,10 +462,10 @@ describe('reshapeTranscript', () => {
})

it('skips records at or below the render cursor (--no-records)', () => {
const hidden = [assistant('old'), result({ total_cost_usd: 0.1 })]
const hidden = [assistant('old'), result({ cost_usd: 0.1 })]
const cursor = hidden[hidden.length - 1].feed_seq
const { items } = reshapeTranscript(
[...hidden, assistant('new'), result({ total_cost_usd: 0.18 })],
[...hidden, assistant('new'), result({ cost_usd: 0.18 })],
cursor,
)
expect(items.map((i) => i.text)).toEqual(['new'])
Expand Down Expand Up @@ -506,6 +515,33 @@ describe('reshapeTranscript', () => {
)
expect(items.map((i) => i.text)).toEqual(['hello'])
})

it('counts agent records that render nothing, so a shape it cannot read is visible', () => {
// A payload this build's reader returns nothing for.
const shapeless = rec('assistant', { kind: 'assistant', content: [] }, 'claude_code')
expect(reshapeTranscript([shapeless], 0)).toEqual({ items: [], undisplayed: 1 })
expect(reshapeTranscript([assistant('fine'), shapeless], 0).undisplayed).toBe(1)
// A harness this build has no reader for at all: recordToItems returns
// undefined for an unknown record_format, which must count, not throw.
const future = {
...rec('assistant', { kind: 'assistant' }, 'grok'),
record_format: 'grok_native@1',
}
expect(reshapeTranscript([future], 0)).toEqual({ items: [], undisplayed: 1 })
// A payload the reader THROWS on (an unknown kind reaches blocksOf) costs
// one row, never the whole transcript.
const hostile = rec('assistant', { kind: 'video' }, 'claude_code')
expect(reshapeTranscript([assistant('before'), hostile], 0)).toEqual({
items: [expect.objectContaining({ text: 'before' })],
undisplayed: 1,
})
// A readable transcript never warns, and a silent-by-design init doesn't count.
expect(reshapeTranscript([assistant('fine'), result()], 0).undisplayed).toBe(0)
expect(
reshapeTranscript([rec('system', { type: 'system', subtype: 'init' }, 'claude_code')], 0)
.undisplayed,
).toBe(0)
})
})

describe('sessionLogText', () => {
Expand Down
Loading