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
34 changes: 22 additions & 12 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { registerUsage } from './commands/usage'
import { registerAnalytics } from './commands/analytics'
import { registerPing } from './commands/ping'
import { VERSION } from './lib/constants'
import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from './ui/launch'

const program = new Command()

Expand Down Expand Up @@ -46,14 +47,17 @@ registerUsage(program)
registerAnalytics(program)
registerPing(program)

// Any invocation that isn't a known subcommand or a top-level help/version
// request is shorthand for `agent session start --connect ...`. So a bare
// `agent`, and `agent "fix the tests" --model ...`, both forward the prompt
// and every trailing flag through to a fresh connected session. A bare
// `agent` starts the session idle (idle_start): the sandbox spins up and
// Claude Code waits for the first thing typed into the composer, like a
// local `claude`. `agent --help`, `agent --version`, `agent help`, and every
// subcommand dispatch unchanged.
// A bare `agent` opens the multi-session UI: the sidebar of your running
// sessions beside a new-session composer — nothing starts until you type a
// task and hit enter. (Headless callers with no TTY get the old behavior of
// an idle connected start via the shorthand below.)
//
// Any other invocation that isn't a known subcommand or a top-level
// help/version request is shorthand for `agent session start --connect ...`:
// `agent "fix the tests" --model ...` forwards the prompt and every trailing
// flag through to a fresh connected session, which opens in the same UI.
// `agent --help`, `agent --version`, `agent help`, and every subcommand
// dispatch unchanged.
const topLevelCommands = new Set([
'help',
...program.commands.flatMap((c) => [c.name(), ...c.aliases()]),
Expand All @@ -65,8 +69,14 @@ const isTopLevel =
first === '-V' ||
first === '--version' ||
(first !== undefined && topLevelCommands.has(first))
if (!isTopLevel) {
process.argv.splice(2, 0, 'session', 'start', '--connect')
if (first === undefined && canHostSessionsUi()) {
const { runAction } = await import('./lib/output')
await runAction(() =>
runSessionsUi({ buildStartRequest: defaultStartRequest }),
)
} else {
if (!isTopLevel) {
process.argv.splice(2, 0, 'session', 'start', '--connect')
}
await program.parseAsync(process.argv)
}

await program.parseAsync(process.argv)
39 changes: 16 additions & 23 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { runAction } from '../lib/output'
import { sessionUrl } from '../lib/urls'
import { makeOpenSocket, resolveWsBase } from '../lib/stream'
import { ConnectApp } from '../ui/ConnectApp'
import type { AgentSession } from '../lib/types'
import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from '../ui/launch'

// `agent session connect [sessionId]` — the terminal window into a cloud
// session (documents/eng/SESSION_IDE.md §2.6, in the ellipsis monorepo).
Expand Down Expand Up @@ -39,28 +39,10 @@ export function resolveConnectSessionId(
return id
}

// Whether the composer can send to this session, and — when it can't — why.
// Only durable (keyed) sessions have an inbox loop to attend a message;
// single-shot and closed sessions open watch-only. Pure, for tests.
export function connectability(session: AgentSession): {
canSend: boolean
reason?: string
} {
if (!session.session_key) {
return {
canSend: false,
reason: 'this session is single-shot (no durable conversation) — opening watch-only',
}
}
if (session.session_state === 'closed') {
return {
canSend: false,
reason:
'this conversation is closed (a new event on its surface starts a successor) — opening watch-only',
}
}
return { canSend: true }
}
// Whether the composer can send to this session (and why not) — shared with
// the multi-session UI; re-exported so existing imports keep working.
import { connectability } from '../lib/sessions'
export { connectability }

export function registerConnect(session: Command): void {
session
Expand All @@ -82,6 +64,17 @@ Pass --no-input to follow read-only from a script or agent (no TTY needed).`,
.action(async (sessionId: string | undefined, opts: { records: boolean; input: boolean }) => {
await runAction(async () => {
const id = resolveConnectSessionId(sessionId)
// Interactive TTY connects open the multi-session UI (sidebar +
// chat, this session focused). Headless callers, --no-input, and
// --no-records keep the solo single-session renderer (the sidebar is
// useless without a keyboard, and the UI always renders history).
if (opts.records && opts.input && canHostSessionsUi()) {
await runSessionsUi({
initialSessionId: id,
buildStartRequest: defaultStartRequest,
})
return
}
await runConnect(id, opts.records, !opts.input)
})
})
Expand Down
33 changes: 19 additions & 14 deletions src/commands/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
} from '../lib/laptop'
import { openBrowser } from '../lib/auth'
import { registerConnect, runConnect } from './connect'
import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from '../ui/launch'
import { formatStepLine, oneLine, recordText } from '../lib/steps'
import { ApiError } from '../lib/api'
import { resolveToken } from '../lib/config'
Expand Down Expand Up @@ -784,22 +785,26 @@ export function registerSession(program: Command): void {
)
}

// `start --connect`: drop straight into the semantic connect (render the
// conversation, follow it live, send messages through the inbox — the same as
// `session connect`). The connect UI itself renders the sandbox lifecycle
// (creating sandbox → spawning agent process) as it happens and reports a
// terminal status reached before the sandbox ever ran (a preflight/budget gate),
// so there is nothing to wait for out here.
// `start --connect`: drop straight into the multi-session UI focused on the
// fresh session (or the solo connect when no TTY hosts the sidebar). The
// chat renders the sandbox lifecycle (creating sandbox → spawning agent
// process) as it happens and reports a terminal status reached before the
// sandbox ever ran (a preflight/budget gate), so there is nothing to wait
// for out here.
export async function startConnect(session: AgentSession, notice?: string): Promise<void> {
// The start response carries the resolved config identity; hand it to the
// connect UI for the footer meta line (a later GET may not resolve the name).
await runConnect(
session.id,
true,
false,
notice,
session.resolved_config_name ?? session.agent_config_id ?? undefined,
)
// chat for the footer meta line (a later GET may not resolve the name).
const configName = session.resolved_config_name ?? session.agent_config_id ?? undefined
if (canHostSessionsUi()) {
await runSessionsUi({
initialSessionId: session.id,
initialConfigName: configName,
initialNotice: notice,
buildStartRequest: defaultStartRequest,
})
return
}
await runConnect(session.id, true, false, notice, configName)
}

// `--watch` entry point: stream the session's output live over WebSocket, and
Expand Down
187 changes: 187 additions & 0 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { sessionStatusWord } from '@ellipsis-dev/sdk/stream'
import type { AgentSessionWire } from '@ellipsis-dev/sdk'
import type { AgentSession } from './types'

// Pure session-model helpers shared by the connect command and the
// multi-session UI (SessionsApp). No I/O here — everything is testable.

// THE selection marker, everywhere: the one cyan character that says "you
// are here" — it replaces a sidebar row's status dot, a transcript line's
// gutter icon, and the focused composer's prompt. One char, always cyan, so
// the eye finds the cursor instantly anywhere in the console. The thick
// right-arrow is reserved for selection alone; statuses are colored dots.
export const SELECTION_GLYPH = '▶'

// Whether the composer can send to this session, and — when it can't — why.
// Only durable (keyed) sessions have an inbox loop to attend a message;
// single-shot and closed sessions open watch-only.
export function connectability(session: AgentSession): {
canSend: boolean
reason?: string
} {
if (!session.session_key) {
return {
canSend: false,
reason: 'this session is single-shot (no durable conversation) — opening watch-only',
}
}
if (session.session_state === 'closed') {
return {
canSend: false,
reason:
'this conversation is closed (a new event on its surface starts a successor) — opening watch-only',
}
}
return { canSend: true }
}

// The one-word display status for a session row (the SDK's surface-first
// projection over the raw status).
export function rowStatusWord(session: AgentSession): string {
return sessionStatusWord(session as unknown as AgentSessionWire)
}

// Statuses in which the agent is actively doing something (the sidebar's
// "in flight" read; mirrors the chat's spinner statuses).
export function isActiveStatusWord(word: string): boolean {
return ['scheduled', 'starting', 'working', 'retrying', 'running', 'creating_sandbox'].includes(
word,
)
}

// The sidebar row's status marker: one dot, status told by color alone (the
// arrow shape belongs to the selection cursor):
// ● yellow: in flight · ● cyan: your move (waiting) · ● dim: sleeping
// ● green: done/closed · ● red: failed · ● dim red: stopped/cancelled
export function rowGlyph(word: string): { glyph: string; color?: string; dim: boolean } {
if (isActiveStatusWord(word)) return { glyph: '●', color: 'yellow', dim: false }
if (word === 'waiting') return { glyph: '●', color: 'cyan', dim: false }
if (word === 'sleeping' || word === 'idle') return { glyph: '●', dim: true }
if (word === 'error' || word === 'failed') return { glyph: '●', color: 'red', dim: false }
if (word === 'stopped' || word === 'cancelled') return { glyph: '●', color: 'red', dim: true }
// closed / completed and anything unrecognized settles as done.
return { glyph: '●', color: 'green', dim: true }
}

// The row's one-line description: what the session is doing right now
// (live_summary), else what it was asked to do (prompt), else where it came
// from. Whitespace collapsed; the caller truncates to the column.
export function rowDescription(session: AgentSession): string {
const summary = session.live_summary
if (typeof summary === 'string' && summary.trim()) return oneLineText(summary)
const prompt = session.prompt
if (typeof prompt === 'string' && prompt.trim()) return oneLineText(prompt)
const source = typeof session.source === 'string' ? session.source : null
return source ? `${source} session` : 'session'
}

function oneLineText(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}

// The instant the session last did anything visible — what the row's age
// line counts from.
export function lastEventAt(session: AgentSession): string {
const last = session.last_activity_at
if (typeof last === 'string' && last) return last
const msg = session.last_message_at
if (typeof msg === 'string' && msg) return msg
return session.updated_at
}

// Compact age for the row's second line: "12s ago", "2m ago", "3h ago",
// "5d ago". Never negative.
export function shortAge(iso: string, now: Date = new Date()): string {
const seconds = Math.max(0, Math.floor((now.getTime() - Date.parse(iso)) / 1000))
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}

// Whether the conversation is still open (alive/idle) — the sidebar's top
// group. Terminal-status single-shot sessions and closed conversations sink
// to the bottom group.
export function isOpenConversation(session: AgentSession): boolean {
if (session.session_state === 'closed') return false
if (session.session_state === 'running' || session.session_state === 'idle') return true
// No session_state (older rows, laptop syncs): treat non-terminal raw
// statuses as open.
return !['completed', 'error', 'cancelled', 'stopped'].includes(session.status)
}

// Sidebar order: open conversations first, then the rest, each group by most
// recent event first. Stable for equal keys.
export function sortSidebarSessions(sessions: readonly AgentSession[]): AgentSession[] {
const key = (s: AgentSession): number => Date.parse(lastEventAt(s)) || 0
return [...sessions].sort((a, b) => {
const openA = isOpenConversation(a)
const openB = isOpenConversation(b)
if (openA !== openB) return openA ? -1 : 1
return key(b) - key(a)
})
}

// Attention transitions: a session that WAS in flight and now waits for a
// human (waiting/sleeping/idle) deserves the sidebar dot. Pure step function
// over consecutive poll snapshots.
export function attentionFlip(prevWord: string | undefined, nextWord: string): boolean {
if (prevWord === undefined) return false
if (!isActiveStatusWord(prevWord)) return false
return nextWord === 'waiting' || nextWord === 'sleeping' || nextWord === 'idle'
}

// --------------------------- new-session picker ---------------------------

// The agent-selectable models for the new-session composer, most capable
// first (the dashboard's GET /models ordering). Static because /models is a
// dashboard-cookie route the CLI's bearer token can't call; keep in sync
// with model_registry.py's agent_selectable set. `null` id = let the server
// pick (DEFAULT_AGENT_MODEL).
export const COMPOSER_MODELS: ReadonlyArray<{ id: string | null; label: string }> = [
{ id: null, label: 'Default (Claude Opus 4.8)' },
{ id: 'claude-fable-5', label: 'Claude Fable 5' },
{ id: 'claude-opus-4-8', label: 'Claude Opus 4.8' },
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5' },
{ id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
]

// A saved config's display name (the YAML's ellipsis.name), falling back to
// the row id.
export function configDisplayName(config: {
id: string
agent_config: Record<string, unknown>
}): string {
const ellipsis = config.agent_config?.ellipsis
if (ellipsis && typeof ellipsis === 'object') {
const name = (ellipsis as Record<string, unknown>).name
if (typeof name === 'string' && name.trim()) return name
}
return config.id
}

// "owner/name" -> the config-override repository shape.
export function repoOverrideEntry(fullName: string): { owner: string; name: string } | null {
const [owner, name] = fullName.split('/')
if (!owner || !name) return null
return { owner, name }
}

// ------------------------------- layout ---------------------------------

// Which slice of the session cells renders when the list overflows the
// nav (or a dropdown its pane): a window of `capacity` cells keeping
// `selected` in frame, preferring to fill from the start.
export function sidebarSlice(
count: number,
capacity: number,
selected: number,
): { start: number; end: number } {
if (count <= capacity) return { start: 0, end: count }
const cap = Math.max(1, capacity)
let start = Math.min(Math.max(0, selected - Math.floor(cap / 2)), count - cap)
if (selected < start) start = selected
return { start, end: start + cap }
}
Loading