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.15.1",
"@ellipsis-dev/sdk": "^0.16.0",
"chalk": "^5.6.2",
"cli-table3": "^0.6.5",
"commander": "^12.1.0",
Expand Down
28 changes: 11 additions & 17 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { Command } from 'commander'
import React from 'react'
import { render } from 'ink'
import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store'
import { SESSION_STREAM_PROTOCOL_VERSION } from '@ellipsis-dev/sdk/stream'
import { SessionTranscriptStore, seedTranscriptStore } from '@ellipsis-dev/sdk/store'
import { api } from '../lib/api'
import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config'
import { runAction } from '../lib/output'
Expand Down Expand Up @@ -118,26 +117,21 @@ export async function runConnect(
// The footer carries the session identity/status; a watch-only reason
// surfaces as the app's notice.

// Seed ONE transcript store with the stored records and the fetched session
// — synthetic frames through the same ingest path the live stream uses, so
// the first paint is instant and streamSession resumes past the seeded
// Seed ONE transcript store with the stored records and the fetched session,
// so the first paint is instant and streamSession resumes past the seeded
// cursor instead of replaying history. --no-records skips *rendering* the
// seeded history (minRenderFeedSeq), not re-streaming it.
const store = new SessionTranscriptStore()
const page = (await client.sessions.records(sessionId)).response
const ordered = [...page.records].sort((a, b) => a.feed_seq - b.feed_seq)
// Seed the session + open inbox as a synthetic snapshot frame (protocol v3:
// the store folds the inbox from the snapshot projection and the message_*
// records that ride the feed), then replay the records to advance the cursor
// so streamSession resumes past the seeded history rather than re-replaying.
store.ingest({
type: 'snapshot',
protocol: SESSION_STREAM_PROTOCOL_VERSION,
earliest_feed_seq: page.earliest_feed_seq ?? null,
// The cast bridges the SDK's two generated flavors of the same wire shape:
// REST responses mark nullable fields optional, the frame types require
// them. Identical JSON either way.
seedTranscriptStore(store, {
session,
messages: page.messages ?? [],
})
if (ordered.length) store.ingest({ type: 'records_append', records: ordered })
records: page.records,
messages: page.messages,
earliestFeedSeq: page.earliest_feed_seq,
} as Parameters<typeof seedTranscriptStore>[1])

// Written by the app when it exits because the conversation closed (terminal;
// nothing left to reconnect to), so the detach sign-off below stays honest.
Expand Down
138 changes: 138 additions & 0 deletions src/lib/chatItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import {
recordToItems,
type ChatTurn,
type TranscriptItem,
} from '@ellipsis-dev/sdk/store'
import type { SessionRecord } from './types'
import { sessionLogText } from './steps'

// The chat's transcript items, derived from the SDK's shared ChatTurn shape —
// the SAME grouped turns the dashboard's chat renders (store.chatTurns()):
// tool calls paired with their results, turns closed by their result records,
// a failed turn carrying isError. This file maps those turns onto the
// terminal renderer's TranscriptItem vocabulary; everything downstream
// (folds, layout, rows, the scrollback flush) is unchanged.

// The sandbox spawn family: the startup block up top narrates these, so the
// chat skips their turns entirely. Logging them here would bury the
// conversation in provisioning noise.
const SANDBOX_RECORD_TYPES = new Set([
'sandbox_starting',
'sandbox_phase',
'sandbox_output',
'sandbox_ready',
])

// ChatTurns as flat transcript items, in turn order.
//
// Lifecycle turns are reworded through sessionLogText (the SHORT list of
// milestones worth a chat line — asleep, waking, retrying, cancelled), so the
// chat log and the old item path read the same. A wake is ONE line, not two:
// "Waking the session…" settles in place to "Session awake" when the resumed
// record lands, KEEPING ITS KEY, so the scroll anchor and the scrollback
// flush never move.
//
// A turn's closing result record is not itself rendered (its duration and
// cost are bookkeeping; the footer carries the spend), but a failed turn is
// content: turn.isError becomes a red "turn ended with an error" line at the
// turn's end. Pure, for tests.
export function chatTurnsToItems(turns: readonly ChatTurn[]): TranscriptItem[] {
const items: TranscriptItem[] = []
// Index of the "Waking the session…" line still awaiting its outcome.
let wakeAt = -1
for (const turn of turns) {
if (turn.role === 'lifecycle') {
for (const node of turn.nodes) {
if (node.kind !== 'lifecycle') continue
if (SANDBOX_RECORD_TYPES.has(node.recordType)) continue
if (node.recordType === 'session_resumed' && wakeAt >= 0) {
items[wakeAt] = { ...items[wakeAt], text: 'Session awake' }
wakeAt = -1
continue
}
const text = sessionLogText(node.recordType, node.payload ?? {})
if (!text) continue
items.push({ key: node.key, kind: 'notice', text, spaceBefore: true })
wakeAt = text === 'Waking the session…' ? items.length - 1 : -1
}
continue
}
if (turn.role === 'user') {
for (const node of turn.nodes) {
if (node.kind === 'user') {
items.push({ key: node.key, kind: 'user', text: node.text, spaceBefore: true })
}
}
continue
}
for (const node of turn.nodes) {
if (node.kind === 'assistant') {
items.push({ key: node.key, kind: 'assistant', text: node.text, spaceBefore: true })
} else if (node.kind === 'thinking') {
items.push({ key: node.key, kind: 'thinking', gutter: '✻', text: node.text, spaceBefore: true })
} else if (node.kind === 'tool') {
// An orphaned result (its call was never seen — replay can start
// mid-burst) renders as the ⎿ result alone, not under a made-up call.
const orphan = node.name === 'tool' && node.input === null && node.startedAt === null
if (!orphan) {
items.push({
key: node.key,
kind: 'tool',
gutter: '●',
text: node.name,
detail: node.summary ? `(${node.summary})` : undefined,
spaceBefore: true,
tool: { name: node.name, input: node.input ?? undefined },
})
}
// The result rides directly under its call — the pairing is the
// point of the ChatTurn shape. A call still running has none, which
// is what pendingToolCalls keys the live activity line off.
if (node.result !== null) {
items.push({
key: `${node.key}:r`,
kind: 'tool_result',
gutter: '⎿',
text: node.result || '(no output)',
spaceBefore: false,
isError: node.isError || undefined,
})
}
}
}
if (turn.isError) {
items.push({
key: `${turn.key}:err`,
kind: 'summary',
text: 'turn ended with an error',
spaceBefore: true,
isError: true,
})
}
}
return items
}

// Agent records that arrived and would render 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. A record the reader THROWS on must cost one count, not
// the whole transcript. Pure, for tests.
export function undisplayedRecordCount(
records: readonly SessionRecord[],
minRenderFeedSeq: number,
): number {
let undisplayed = 0
for (const r of records) {
if (r.feed_seq <= minRenderFeedSeq || r.source === 'lifecycle') continue
let rendered: TranscriptItem[]
try {
rendered = recordToItems(r, `s${r.feed_seq}`) ?? []
} catch {
rendered = []
}
if (rendered.length === 0 && r.record_type !== 'system') undisplayed++
}
return undisplayed
}
36 changes: 32 additions & 4 deletions src/lib/steps.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
import { lifecycleText as sdkLifecycleText, oneLine } from '@ellipsis-dev/sdk/store'
import {
deriveSandboxState as sdkDeriveSandboxState,
lifecycleText as sdkLifecycleText,
sessionLogText as sdkSessionLogText,
oneLine,
type SandboxState,
} from '@ellipsis-dev/sdk/store'
import { formatTs } from './output'
import type { SessionRecord } from './types'

// Re-exported for the record-view callers below and their historical
// importers; the implementations live in the SDK's store layer now.
export { oneLine, sandboxOutputStep, sandboxOutputLine } from '@ellipsis-dev/sdk/store'

// The SDK's lifecycle wording, with its middot separators as commas — the CLI
// writes plain sentences.
// The SDK's wording, with its middot separators as commas — the CLI writes
// plain sentences.
const commas = (text: string): string => text.replaceAll(' · ', ', ')

export function lifecycleText(
...args: Parameters<typeof sdkLifecycleText>
): string | null {
return sdkLifecycleText(...args)?.replaceAll(' · ', ', ') ?? null
const text = sdkLifecycleText(...args)
return text === null ? null : commas(text)
}

export function sessionLogText(
...args: Parameters<typeof sdkSessionLogText>
): string | null {
const text = sdkSessionLogText(...args)
return text === null ? null : commas(text)
}

export function deriveSandboxState(
...args: Parameters<typeof sdkDeriveSandboxState>
): SandboxState | null {
const state = sdkDeriveSandboxState(...args)
if (!state) return null
return {
...state,
headline: commas(state.headline),
log: state.log.map((line) => ({ ...line, text: commas(line.text) })),
}
}

// Record-rendering helpers shared by `session records` and `session connect`
Expand Down
Loading