diff --git a/src/cli.tsx b/src/cli.tsx index 44becf8..927e683 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -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() @@ -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()]), @@ -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) diff --git a/src/commands/connect.ts b/src/commands/connect.ts index c7dbc1d..1b532f1 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -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). @@ -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 @@ -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) }) }) diff --git a/src/commands/session.tsx b/src/commands/session.tsx index d9c7b2a..7c3d60e 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -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' @@ -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 { // 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 diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts new file mode 100644 index 0000000..252c212 --- /dev/null +++ b/src/lib/sessions.ts @@ -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 { + const ellipsis = config.agent_config?.ellipsis + if (ellipsis && typeof ellipsis === 'object') { + const name = (ellipsis as Record).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 } +} diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index ea6613b..c3a4931 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -34,6 +34,7 @@ import { import { ApiClient, ApiError } from '../lib/api' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' +import { SELECTION_GLYPH } from '../lib/sessions' import { VERSION } from '../lib/constants' // The interactive `agent session connect` UI, modelled on Claude Code: a @@ -82,6 +83,31 @@ export interface ConnectAppProps { // conversation closed, so the caller skips the "detached — still running" // sign-off (the session is not still running). exitState?: { closed: boolean } + // ---- pane hosting (the multi-session UI) ---- + // When set, the app renders inside a fixed-size pane instead of sizing to + // the terminal: wrap math and the viewport budget use these instead of + // stdout's rows/columns. + paneWidth?: number + paneHeight?: number + // Blank rows above the first line. The solo app keeps TOP_PAD's slack + // against terminal row-accounting quirks; a pane host owns its own edges. + topPad?: number + // Whether this pane owns the keyboard. The host keeps exactly one input + // handler active (sidebar or chat); default true for the solo app. + focused?: boolean + // Leave the pane and hand focus to the host's session nav (the bar below + // the composer). Fired by esc with nothing open (no panel, no transcript + // nav) and by ↓ at the bottom edge (the composer's last line, or a + // watch-only follow). Absent in the solo app, where neither leaves. + onFocusNav?: () => void + // Drop the bottom meta line (status · cost · model · id · version): the + // host renders it in its own header instead. + hideMetaLine?: boolean + // Terminal outcomes, reported to the host INSTEAD of exiting the app: + // 'closed' = the conversation closed; 'preflight' = the session died + // before it became connectable; 'ended' = a watch-only stream finished. + // Absent in the solo app, which exits the Ink render instead. + onDone?: (reason: 'closed' | 'preflight' | 'ended') => void } // Surface statuses in which something is actively happening — drives the @@ -132,6 +158,32 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } }, [stdout]) + // Hosted-in-a-pane vs owning the terminal (the solo `connect`). A pane host + // fixes the size and owns the window edges (no shell-row or sign-off slack), + // so hosted mode drops the solo app's -1 bottom row and defaults topPad to 1. + const hosted = props.paneWidth != null || props.paneHeight != null + const rows = props.paneHeight ?? termRows + const cols = props.paneWidth ?? termCols + const topPad = props.topPad ?? (hosted ? 1 : TOP_PAD) + const focused = props.focused ?? true + const bottomSlack = hosted ? 0 : 1 + + // Terminal outcomes: reported to the host when there is one (the app stays + // mounted; the host decides what to show), otherwise exit the Ink render. + // The callback rides a ref so `finish` (and everything memoized on it, the + // stream pump above all) stays stable even when the host passes a fresh + // closure every render — a changing pump identity would abort the socket. + const onDoneRef = useRef(props.onDone) + onDoneRef.current = props.onDone + const hasHost = props.onDone != null + const finish = useCallback( + (reason: 'closed' | 'preflight' | 'ended'): void => { + if (onDoneRef.current) onDoneRef.current(reason) + else exit() + }, + [exit], + ) + // The store snapshot is the single source of truth for everything streamed. const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) const statusWord = snapshot.session ? sessionStatusWord(snapshot.session) : 'starting' @@ -156,7 +208,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // startup block, else a TranscriptItem key), or null while the composer has // focus. Up from the composer's first line enters at the newest line; down // past the newest line (or esc) returns to the composer. The highlighted - // line renders a › gutter in cyan. + // line renders the cyan selection glyph in its gutter. const [navKey, setNavKey] = useState(null) // Lines opened in place with → while highlighted: a grp:* fold expands into // its tool calls, a clamped long body un-clamps. ← closes them again. @@ -278,8 +330,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { closingDown.current = true if (props.exitState) props.exitState.closed = true setNotice('conversation closed') - exit() - }, [exit, props.exitState]) + finish('closed') + }, [finish, props.exitState]) useEffect(() => { if (statusWord === 'closed') finishClosed() }, [statusWord, finishClosed]) @@ -339,8 +391,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ['failed', 'cancelled', 'stopped'].includes(outcome.status) ) { setNotice(`session ${outcome.status} before it became connectable`) - process.exitCode = 1 - exit() + if (!hasHost) process.exitCode = 1 + finish('preflight') return } // The warm loop can end by closing the conversation (a closing event's @@ -353,7 +405,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if (canSend) { if (outcome.type === 'done') setNotice('agent idle — send a message to wake it') } else { - exit() + finish('ended') } }) .catch((err: unknown) => { @@ -365,12 +417,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return } setNotice(`stream error: ${(err as Error).message}`) - if (!canSend) exit() + if (!canSend) finish('ended') }) .finally(() => { streaming.current = false }) - }, [canSend, exit, finishClosed, openSocket, sessionId, startPollFallback, store]) + }, [canSend, finish, finishClosed, hasHost, openSocket, sessionId, startPollFallback, store]) useEffect(() => { pump() @@ -482,7 +534,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { [api, exit, pump, sessionId], ) - const inputActive = canSend && isRawModeSupported + // The composer renders whenever sending is possible. The keyboard handler + // is gated on pane focus (the host keeps exactly one handler active) but + // NOT on sendability: a hosted watch-only chat (closed conversation, + // single-shot session) still navigates, scrolls, and hands focus back with + // esc — its composer-editing keys are ignored below. The solo watch-only + // follow keeps today's no-input behavior. + const composerVisible = canSend && isRawModeSupported + const inputActive = (composerVisible || (hosted && isRawModeSupported)) && focused // Mouse reporting (SGR), so wheel/trackpad scroll reaches the app as input // instead of scrolling the terminal's (empty) scrollback. Capturing the @@ -500,21 +559,27 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // The rendered transcript lines, in order: collapsed (the default) folds // consecutive tool activity into "Ran N …" notices, except folds opened in // place with → (openedKeys), which render their tool calls right below the - // fold line so ← can close them again. Expanded (ctrl+r) shows everything. - const visible = useMemo(() => { + // fold line — indented one level (2 columns), so the expansion reads as + // the fold's children — and ← closes them again. Expanded (ctrl+r) shows + // everything, flat. `indented` carries the keys of fold-child lines. + const { visible, indented } = useMemo(() => { const pendingKeys = new Set(pendingTools.map((t) => t.key)) const base = pendingKeys.size ? items.filter((i) => !pendingKeys.has(i.key)) : items - if (expanded) return items + if (expanded) return { visible: items, indented: new Set() } const folded = collapseToolRuns(base) - if (openedKeys.size === 0) return folded + if (openedKeys.size === 0) return { visible: folded, indented: new Set() } const out: TranscriptItem[] = [] + const indentedKeys = new Set() for (const item of folded) { out.push(item) if (item.key.startsWith('grp:') && openedKeys.has(item.key)) { - out.push(...foldRun(item.key, base)) + for (const child of foldRun(item.key, base)) { + out.push(child) + indentedKeys.add(child.key) + } } } - return out + return { visible: out, indented: indentedKeys } }, [items, expanded, pendingTools, openedKeys]) // Everything ↑/↓ can highlight, top to bottom: the sandbox startup block @@ -525,7 +590,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // and snapped to the highlight. const infraActivity = statusActivityText(statusWord) const entries = useMemo(() => { - const width = Math.max(20, termCols - 3) + const width = Math.max(20, cols - 3) const keys: string[] = [] const heights: number[] = [] const byKey = new Map() @@ -537,13 +602,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { keys.push(item.key) byKey.set(item.key, item) // Assistant messages wrap inside their 80% column (the right 20% is - // the metadata gutter), so their height estimate uses that width. + // the metadata gutter), so their height estimate uses that width; an + // opened fold's indented children lose 2 columns to the indent. + const itemWidth = item.kind === 'assistant' + ? Math.max(20, Math.floor(width * 0.8)) + : indented.has(item.key) + ? Math.max(20, width - 2) + : width heights.push( - estimateItemRows( - item, - item.kind === 'assistant' ? Math.max(20, Math.floor(width * 0.8)) : width, - !expanded && !openedKeys.has(item.key), - ), + estimateItemRows(item, itemWidth, !expanded && !openedKeys.has(item.key)), ) } return { keys, heights, byKey } @@ -551,12 +618,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { infraActivity, sandbox, visible, + indented, sandboxOpen, stepLogsOpen, stepCursor, expanded, openedKeys, - termCols, + cols, ]) // Everything ↑/↓ can actually land on: the entry list minus turn summaries // ("turn complete · 3s · $0.03"), which are informational trailers, not @@ -575,8 +643,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // "… N above/below" indicator rows. Heights are estimates, so this leans // conservative rather than overflow the window into scrollback. const viewBudget = useMemo(() => { - const width = Math.max(20, termCols - 3) - const composerRows = inputActive ? 3 + composer.text.split('\n').length : 0 + const width = Math.max(20, cols - 3) + // The composer box: its rules (2, or 1 when the host's nav rule serves + // as the bottom) plus a 3-row minimum interior (grows with a taller + // multi-line input). + const composerRows = composerVisible + ? (props.hideMetaLine ? 1 : 2) + Math.max(3, composer.text.split('\n').length) + : 0 // Live prose wraps inside the same 80% column its committed form uses. const liveWidth = Math.max(20, Math.floor(width * 0.8)) const liveReserve = working @@ -592,17 +665,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { q.text.split('\n').reduce((a, l) => a + Math.max(1, Math.ceil(l.length / width)), 0), 0, ) - const footerRows = 1 + (notice ? 1 : 0) + composerRows + 1 /* footer margin */ - return Math.max(3, termRows - 1 - TOP_PAD - footerRows - liveReserve - queuedReserve - 2) + const footerRows = + (props.hideMetaLine ? 0 : 1) + (notice ? 1 : 0) + composerRows + 1 /* footer margin */ + return Math.max(3, rows - bottomSlack - topPad - footerRows - liveReserve - queuedReserve - 2) }, [ - termRows, - termCols, - inputActive, + rows, + cols, + bottomSlack, + topPad, + composerVisible, composer.text, working, snapshot.liveText, notice, inFlightSends, + props.hideMetaLine, ]) // The viewport slice for a given scroll anchor (pure math in viewportSlice; @@ -676,7 +753,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } if (key.escape) { // Modal-first: an open sandbox panel closes, then transcript - // navigation drops back to the composer, before esc means "stop". + // navigation drops back to the composer, then esc leaves the pane. if (sandboxOpen) { setSandboxOpen(false) setStepLogsOpen(false) @@ -687,7 +764,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { setScrollKey(null) return } - if (working) submit('/stop') + // Hosted: hand focus to the session nav (stopping is the composer's + // /stop command). Solo: esc-while-working keeps meaning stop. + if (props.onFocusNav) props.onFocusNav() + else if (working) submit('/stop') return } if (key.ctrl && ch === 'r') { @@ -765,6 +845,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return } if (key.leftArrow) { + // ← closes the thing opened in place; with nothing open it's inert + // (the session nav lives BELOW the composer — ↓ walks to it). if (openedKeys.has(navKey)) { setOpenedKeys((prev) => { const next = new Set(prev) @@ -774,13 +856,25 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } return } - if (ch && !key.ctrl && !key.meta) { + if (ch && !key.ctrl && !key.meta && composerVisible) { setNavKey(null) setScrollKey(null) insertAtCursor(ch) } return } + // Below here are the composer's own keys. Watch-only (no composer): + // ↑ still enters transcript navigation, ↓ leaves for the session nav; + // everything else is inert. + if (!composerVisible) { + if (key.upArrow && navKeys.length > 0) { + setNavKey(navKeys[navKeys.length - 1]) + setScrollKey(null) + } else if (key.downArrow && props.onFocusNav) { + props.onFocusNav() + } + return + } if (key.return) { submit(composer.text) return @@ -797,8 +891,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return } if (key.downArrow) { + // Down inside a multi-line input walks a line; down on the last + // line moves focus to the host's session nav (the bar below). const down = cursorLineDown(composer.text, composer.cursor) if (down !== null) setComposer((c) => ({ ...c, cursor: down })) + else if (props.onFocusNav) props.onFocusNav() return } if (key.leftArrow) { @@ -857,13 +954,26 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // session frames' cost columns, climbing mid-turn); the CC-derived result // total is the fallback against older backends. const totalStr = `$${(serverCostUsd ?? cost.total ?? 0).toFixed(2)}` - const metaLine = [ + // Sized to fit by construction: Ink measures the OSC-8 hyperlink's + // invisible URL bytes as width, so a linked id only ships when the whole + // line — escape bytes included — fits the pane; otherwise the id renders + // as plain text, shortened if even that overflows. Never rely on Ink + // wrapping/truncating this line: it's budgeted at exactly one row. + const metaParts = (id: string): string[] => [ `${statusWord} · ${totalStr} total`, ...(props.model ? [props.model] : []), - hyperlink(props.sessionUrl, sessionId), + id, ...(props.configName ? [props.configName] : []), `v${VERSION}`, - ].join(' · ') + ] + const linked = metaParts(hyperlink(props.sessionUrl, sessionId)).join(' · ') + const plain = metaParts(sessionId).join(' · ') + const metaLine = + linked.length < cols + ? linked + : plain.length < cols + ? plain + : metaParts(`${sessionId.slice(0, 20)}…`).join(' · ') // Three distinct, factual activity signals — never whimsy. All render IN the // transcript, on the block they describe, not above the composer: // - `infraActivity`: the sandbox is spawning/waking (scheduled/starting/ @@ -892,11 +1002,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { : `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})` return ( - + // Hosted panes pin BOTH dimensions: without the width the root sizes to + // its widest child (the unwrapped meta line) and smears rows across the + // terminal; without the fixed height an over-estimated transcript slice + // grows the root past the pane and the host clips the footer off. Fixed, + // the squeeze resolves inside (the overflow-hidden viewport absorbs it). + {/* Top padding — see the termRows comment: absorbs terminal row- accounting quirks and the post-exit sign-off so the first content line never scrolls out of the window. */} - + {topPad > 0 && } {/* The one-line opener is the whole banner — the rest of the session identity (dashboard link, model, version) lives in the footer meta line, so nothing is printed to scrollback before the app. */} @@ -920,11 +1040,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ✦ Connected to ellipsis.dev - {/* Level 1: the session headline. The › replaces the mark while - highlighted (same 1-char slot), so the header never shifts. */} + {/* Level 1: the session headline. The selection glyph replaces the + mark while highlighted (same 1-char slot), so the header never + shifts. */} {navKey === 'sandbox' ? ( - + {SELECTION_GLYPH} ) : sandbox?.done && !infraActivity ? ( ) : ( @@ -935,6 +1056,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { while starting it stays dim like the rest of the block. */} @@ -997,18 +1119,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ) return ( - {/* The › cursor column is always reserved (a space when + {/* The cursor column is always reserved (a space when unselected), so opening the panel never shifts the rows; the selected phase reads cyan like the transcript highlight. */} {step.child ? ' ' : ' '} - {selected ? '›' : ' '} {mark}{' '} + {selected ? SELECTION_GLYPH : ' '} {mark}{' '} - {oneLine(sandboxStepLine(step), 108)} + {selected + ? ` ${oneLine(sandboxStepLine(step), 106)} ` + : oneLine(sandboxStepLine(step), 108)} {sandboxOpen && step.lines.length > 0 && ( @@ -1047,8 +1172,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {/* The transcript viewport grows through the middle of the terminal, pinning the composer + meta to the bottom edge (flexGrow fills the slack). Only the slice that fits the window renders; dim markers - show what's out of frame above/below. */} - + show what's out of frame above/below. Row heights are estimates, so + an overshooting slice clips here (overflow) instead of squashing + the footer (the composer's borders collapse onto its prompt row). */} + {slice.start > 0 && ( … {slice.start} earlier (scroll or ↑) )} @@ -1064,6 +1191,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { expanded={expanded} opened={openedKeys.has(k)} selected={navKey === k} + indent={indented.has(k)} /> ) })} @@ -1191,26 +1319,49 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} - + {/* flexShrink=0: when a mis-estimated transcript slice overflows the + fixed pane, the squeeze lands on the (overflow-hidden) viewport + above, never on the composer/meta rows. */} + {notice && · {notice}} {/* The composer, framed by a full-width rule above and below (Claude Code-style): the › prompt sits between the two lines. Only the top and bottom borders are drawn, so the rules span the terminal width. */} - {inputActive && ( - + {/* The composer: a 3-row input area framed by full-width top and + bottom rules only (no side borders). With hideMetaLine the host + draws its own full-width rule directly beneath (the nav band), + which serves as the bottom bounding line — skip ours so the two + don't stack. */} + {composerVisible && ( + {/* One parent Text so a multi-line input flows as a single block (sibling Texts in a row Box would render as columns). The caret is the inverse cell at the cursor, hidden while the - transcript has focus (navKey). A caret sitting on a newline - renders as an inverse space at that line's end. The key - remounts the node on every content change: ink reuses the + transcript has focus (navKey) and while the pane itself is + unfocused (the sidebar has the keyboard). A caret sitting on + a newline renders as an inverse space at that line's end. The + key remounts the node on every content change: ink reuses the previous measurement when nested text mutates in place, and the stale (narrower) width wraps the caret onto the border row below. */} - - + {/* The prompt is the selection glyph while the composer is where + you are (focused, no transcript highlight) — the same cyan + marker as everywhere else — and dim when it isn't. */} + + + {SELECTION_GLYPH}{' '} + {composer.text.slice(0, composer.cursor)} - {navKey === null && ( + {focused && navKey === null && ( {composer.cursor < composer.text.length && composer.text[composer.cursor] !== '\n' @@ -1219,14 +1370,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} {composer.cursor < composer.text.length - ? navKey === null && composer.text[composer.cursor] !== '\n' + ? focused && navKey === null && composer.text[composer.cursor] !== '\n' ? composer.text.slice(composer.cursor + 1) : composer.text.slice(composer.cursor) : ''} )} - {metaLine} + {!props.hideMetaLine && {metaLine}} ) @@ -1883,6 +2034,7 @@ const TranscriptLine = React.memo(function TranscriptLine({ expanded, opened, selected, + indent = false, }: { item: TranscriptItem // The step's metadata ("4s · $0.10"), attached to a turn's closing @@ -1891,8 +2043,13 @@ const TranscriptLine = React.memo(function TranscriptLine({ expanded: boolean // This line was opened in place with → while highlighted (un-clamps it). opened: boolean - // This line is the transcript-navigation highlight: › gutter, cyan text. + // This line is the transcript-navigation highlight: cyan selection glyph + // in the gutter, cyan text. selected: boolean + // This line is an opened fold's child ("Ran 2 …" → its tool calls): the + // whole row shifts one level (2 columns) right, so the expansion reads as + // the fold's children in the chat hierarchy. + indent?: boolean }): React.ReactElement { const mt = item.spaceBefore ? 1 : 0 const { gutterColor, textColor, dim, bold } = styleFor(item) @@ -1914,13 +2071,13 @@ const TranscriptLine = React.memo(function TranscriptLine({ // message closed one). Other kinds span the full width as before. const isAssistant = item.kind === 'assistant' return ( - + - {selected ? '›' : gutterFor(item)} + {selected ? SELECTION_GLYPH : gutterFor(item)} - + {/* The selected line renders as an inverse cyan bar — the app-wide + "you are here" treatment (sidebar rows, buttons, dropdown + options all match). */} + {clamped.body} {item.detail ? ( - + {item.detail} ) : null} diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx new file mode 100644 index 0000000..d18e0f3 --- /dev/null +++ b/src/ui/SessionsApp.tsx @@ -0,0 +1,997 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink' +import type { OpenSocket } from '@ellipsis-dev/sdk/stream' +import { SESSION_STREAM_PROTOCOL_VERSION, sessionStatusWord } from '@ellipsis-dev/sdk/stream' +import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' +import type { AgentSessionWire } from '@ellipsis-dev/sdk' +import type { ApiClient } from '../lib/api' +import { ApiError } from '../lib/api' +import type { + AgentSession, + SavedAgentConfig, + StartAgentSessionRequest, +} from '../lib/types' +import { hyperlink, sessionUrl } from '../lib/urls' +import { usdNumberFromMillicents } from '../lib/output' +import { VERSION } from '../lib/constants' +import { + attentionFlip, + COMPOSER_MODELS, + configDisplayName, + connectability, + lastEventAt, + repoOverrideEntry, + rowDescription, + rowGlyph, + rowStatusWord, + SELECTION_GLYPH, + shortAge, + sidebarSlice, + sortSidebarSessions, +} from '../lib/sessions' +import { ConnectApp } from './ConnectApp' + +// The multi-session UI, a vertical stack of four bands: +// 1. the header — " ellipsis.dev" plus the focused session's meta +// (status · cost · model · id · version), closed by a rule +// 2. the chat window (the hosted ConnectApp, full width) +// 3. the text input (the ConnectApp's composer) +// 4. the session nav — your running sessions as a horizontal bar +// This is what a bare `agent`, `agent "prompt"`, and `agent session +// connect ` all open. +// +// Focus is modal and esc steps outward: inside the chat esc closes panels, +// then transcript navigation, then lands on the nav bar. ↓ at the composer's +// last line reaches the nav too; enter (or ↑/esc) hands it back. Exactly one +// useInput handler is active at a time. +// +// Liveness: ONE WebSocket — the focused session's, owned by its ConnectApp — +// plus a 5s REST poll of the session list for the nav. Transcript stores are +// cached per visited session for the process lifetime, so hopping back +// repaints instantly and the stream resumes past the cached cursor. + +const SIDEBAR_POLL_MS = 5_000 +const SIDEBAR_LIMIT = 50 +// The nav clock driving the "12s" age tags. +const AGE_TICK_MS = 5_000 +// One nav cell: dot + truncated description + age, fixed width so the bar +// windows predictably. +const NAV_ITEM_W = 30 + +export interface SessionsAppProps { + api: ApiClient + openSocket: OpenSocket + // app.ellipsis.dev base + the customer login, for per-session dashboard links. + appBase: string + customerLogin: string + // My GitHub account id — the sidebar lists sessions attributed to me. null + // (e.g. an API-key credential) lists the whole account's sessions. + authorId: number | null + // Open focused on this session (connect / prompt shorthand); undefined + // opens on the new-session composer (a bare `agent`). + initialSessionId?: string + // The start response's resolved config name for the initial session, and a + // caveat to show in its chat (watch-only reasons ride connectability). + initialConfigName?: string + initialNotice?: string + // Builds the start request for a composer-spawned session (the entry point + // owns repository detection and defaults). + buildStartRequest: (prompt: string) => StartAgentSessionRequest +} + +// Everything the chat pane needs for one session, cached across hops. +type ChatEntry = { + store: SessionTranscriptStore + canSend: boolean + notice: string | null + model: string | null + configName: string | null + url: string +} + +type MainPane = { type: 'new' } | { type: 'chat'; sessionId: string } + +export function SessionsApp(props: SessionsAppProps): React.ReactElement { + const { api, openSocket, appBase, customerLogin, authorId } = props + const { exit } = useApp() + const { isRawModeSupported } = useStdin() + const { stdout } = useStdout() + + const [termRows, setTermRows] = useState(stdout?.rows ?? 24) + const [termCols, setTermCols] = useState(stdout?.columns ?? 80) + useEffect(() => { + if (!stdout) return + const onResize = (): void => { + setTermRows(stdout.rows) + setTermCols(stdout.columns) + } + stdout.on('resize', onResize) + return () => { + stdout.off('resize', onResize) + } + }, [stdout]) + const height = Math.max(8, termRows - 1) + + // ------------------------------ sidebar data ------------------------------ + + const [sessions, setSessions] = useState([]) + const [polledOnce, setPolledOnce] = useState(false) + // Sessions whose status flipped active → waiting since last viewed: the + // "an agent is blocked on you" dot. Cleared when the row is opened. + const [attention, setAttention] = useState>(new Set()) + const lastWords = useRef(new Map()) + // Composer-spawned sessions the poll may not return yet (created < poll + // lag, or attributed differently); merged into the list until it does. + const [localSessions, setLocalSessions] = useState([]) + + const poll = useCallback(async (): Promise => { + try { + const listed = await api.listAgentSessions({ + author_id: authorId ?? undefined, + limit: SIDEBAR_LIMIT, + }) + setAttention((prev) => { + const next = new Set(prev) + for (const s of listed) { + const word = rowStatusWord(s) + if (attentionFlip(lastWords.current.get(s.id), word)) next.add(s.id) + lastWords.current.set(s.id, word) + } + return next.size === prev.size ? prev : next + }) + setSessions(listed) + setLocalSessions((prev) => prev.filter((l) => !listed.some((s) => s.id === l.id))) + setPolledOnce(true) + } catch { + // Transient poll failure — keep the previous list; the next tick retries. + } + }, [api, authorId]) + + useEffect(() => { + void poll() + const t = setInterval(() => void poll(), SIDEBAR_POLL_MS) + return () => clearInterval(t) + }, [poll]) + + // The age lines tick on their own clock (nothing else re-renders idle rows). + const [, setAgeTick] = useState(0) + useEffect(() => { + const t = setInterval(() => setAgeTick((n) => n + 1), AGE_TICK_MS) + return () => clearInterval(t) + }, []) + + const rows = useMemo( + () => sortSidebarSessions([...localSessions, ...sessions]), + [localSessions, sessions], + ) + + // ------------------------------ focus + panes ----------------------------- + + // Both openings start with the main pane focused: a connect lands you in + // the conversation, a bare `agent` lands you in the new-session composer. + // 'nav' = the session bar at the bottom owns the keyboard. + const [focus, setFocus] = useState<'nav' | 'chat'>('chat') + const [mainPane, setMainPane] = useState( + props.initialSessionId ? { type: 'chat', sessionId: props.initialSessionId } : { type: 'new' }, + ) + // The nav highlight: 'new' or a session id. Tracks ids, not indices, so + // the poll re-sorting under the cursor doesn't move the highlight. + const [selected, setSelected] = useState(props.initialSessionId ?? 'new') + + // ------------------------------ chat entries ------------------------------ + + const [entries, setEntries] = useState>(new Map()) + const [loadError, setLoadError] = useState(null) + const loading = useRef(new Set()) + + // Seed a transcript store exactly like the solo connect: a synthetic + // snapshot frame (session + open inbox) then the stored records, so the + // first paint is instant and the stream resumes past the seeded cursor. + const loadEntry = useCallback( + async (sessionId: string, configName?: string, notice?: string): Promise => { + if (loading.current.has(sessionId)) return + loading.current.add(sessionId) + setLoadError(null) + try { + const [session, page] = await Promise.all([ + api.getAgentSession(sessionId), + api.getAgentSessionRecordsPage(sessionId), + ]) + const store = new SessionTranscriptStore() + const ordered = [...page.records].sort((a, b) => a.feed_seq - b.feed_seq) + store.ingest({ + type: 'snapshot', + protocol: SESSION_STREAM_PROTOCOL_VERSION, + earliest_feed_seq: page.earliest_feed_seq ?? null, + session, + messages: page.messages ?? [], + }) + if (ordered.length) store.ingest({ type: 'records_append', records: ordered }) + const c = connectability(session) + const entry: ChatEntry = { + store, + canSend: c.canSend, + notice: [notice, c.reason].filter(Boolean).join(' · ') || null, + model: typeof session.tokens_model === 'string' ? session.tokens_model : null, + configName: + configName ?? session.resolved_config_name ?? session.agent_config_id ?? null, + url: sessionUrl(appBase, customerLogin, sessionId), + } + setEntries((prev) => new Map(prev).set(sessionId, entry)) + } catch (err) { + setLoadError(err instanceof ApiError ? err.detail : (err as Error).message) + loading.current.delete(sessionId) + return + } + loading.current.delete(sessionId) + }, + [api, appBase, customerLogin], + ) + + // Load the focused session's entry on demand (initial focus included). + useEffect(() => { + if (mainPane.type !== 'chat') return + if (entries.has(mainPane.sessionId)) return + void loadEntry( + mainPane.sessionId, + mainPane.sessionId === props.initialSessionId ? props.initialConfigName : undefined, + mainPane.sessionId === props.initialSessionId ? props.initialNotice : undefined, + ) + }, [mainPane, entries, loadEntry, props.initialSessionId, props.initialConfigName, props.initialNotice]) + + // ----------------------------- new session flow --------------------------- + + const [starting, setStarting] = useState(false) + const [startError, setStartError] = useState(null) + + // The composer's picker options, fetched once when the new-session pane + // first opens: saved agent configs and the account's repositories (the + // dashboard composer's choices; models are the static selectable set). + const [configs, setConfigs] = useState(null) + const [repos, setRepos] = useState(null) + const pickersLoading = useRef(false) + useEffect(() => { + if (mainPane.type !== 'new' || pickersLoading.current) return + pickersLoading.current = true + void api + .listAgentConfigs() + .then((rows) => setConfigs(rows.filter((c) => !c.deleted))) + .catch(() => setConfigs([])) + void api + .listGithubRepositories() + .then((r) => setRepos(r.repositories.map((repo) => repo.full_name))) + .catch(() => setRepos([])) + }, [mainPane.type, api]) + + const startSession = useCallback( + async ( + prompt: string, + choices: { configId: string | null; model: string | null; repos: string[] }, + ): Promise => { + setStarting(true) + setStartError(null) + try { + // The entry point's base request (prompt + detected repository), + // with the composer's picks layered on: a saved config as the + // source, the model + repositories as a per-run override (the + // dashboard composer's shape — lists replace wholesale, so the + // checked repos become the run's whole checkout set). + const req = props.buildStartRequest(prompt) + if (choices.configId) req.config_id = choices.configId + const override: Record = {} + if (choices.model) override.claude = { model: choices.model } + const repoEntries = choices.repos + .map(repoOverrideEntry) + .filter((e): e is { owner: string; name: string } => e !== null) + if (repoEntries.length > 0) override.sandbox = { repositories: repoEntries } + if (Object.keys(override).length > 0) req.config_override = override + const session = await api.startAgentSession(req) + lastWords.current.set(session.id, rowStatusWord(session)) + setLocalSessions((prev) => [session, ...prev]) + setSelected(session.id) + setMainPane({ type: 'chat', sessionId: session.id }) + setFocus('chat') + // Seed the entry from the start response's resolved config identity. + void loadEntry( + session.id, + session.resolved_config_name ?? session.agent_config_id ?? undefined, + ) + } catch (err) { + setStartError(err instanceof ApiError ? err.detail : (err as Error).message) + } finally { + setStarting(false) + } + }, + [api, loadEntry, props], + ) + + // -------------------------------- nav input -------------------------------- + + const openSelected = useCallback((): void => { + if (selected === 'new') { + setMainPane({ type: 'new' }) + setFocus('chat') + return + } + setAttention((prev) => { + if (!prev.has(selected)) return prev + const next = new Set(prev) + next.delete(selected) + return next + }) + setMainPane({ type: 'chat', sessionId: selected }) + setFocus('chat') + }, [selected]) + + // The selectable id list, left to right: the pinned new cell, then sessions. + const selectable = useMemo(() => ['new', ...rows.map((s) => s.id)], [rows]) + + useInput( + (ch, key) => { + // The nav is a horizontal bar: ←/→ move the highlight, enter opens + // the highlighted session, ↑/esc return to the chat. + if (key.leftArrow || key.rightArrow) { + const idx = selectable.indexOf(selected) + const next = key.leftArrow + ? Math.max(0, idx < 0 ? 0 : idx - 1) + : Math.min(selectable.length - 1, idx < 0 ? 0 : idx + 1) + setSelected(selectable[next]) + return + } + if (key.return) { + openSelected() + return + } + if (key.upArrow || key.escape) { + setFocus('chat') + return + } + if (ch === 'n') { + setSelected('new') + setMainPane({ type: 'new' }) + setFocus('chat') + return + } + if (ch === 'q') { + exit() + return + } + }, + { isActive: focus === 'nav' && isRawModeSupported }, + ) + + const focusNav = useCallback((): void => setFocus('nav'), []) + const refreshOnDone = useCallback((): void => { + void poll() + }, [poll]) + + // ------------------------------- rendering -------------------------------- + + // Band heights: header = blank + title line + rule (3); nav = rule + + // 2-line cells + hint (4). The chat band gets the rest. + const headerRows = 3 + const navRows = 4 + const chatRows = Math.max(4, height - headerRows - navRows) + + // ---- band 1: the header ---- + // The focused session's live meta (status · cost · model · id · version), + // derived from its transcript store so it ticks like the old footer did. + const focusedEntry = mainPane.type === 'chat' ? entries.get(mainPane.sessionId) : undefined + const metaText = useHeaderMeta(focusedEntry, mainPane, appBase, customerLogin) + const header = ( + + + + ellipsis.dev + {metaText && · {metaText}} + + {'─'.repeat(Math.max(0, termCols))} + + ) + + // ---- bands 2+3: the chat window + its composer (one hosted component) ---- + let main: React.ReactElement + if (mainPane.type === 'new') { + main = ( + + void startSession(text, choices)} + onLeave={focusNav} + rawMode={isRawModeSupported} + /> + + ) + } else { + const entry = entries.get(mainPane.sessionId) + if (!entry) { + main = ( + + + {loadError ? `✗ ${loadError}` : `loading ${mainPane.sessionId}…`} + + {loadError && esc: back to the sessions} + + + ) + } else { + main = ( + // The chat window + composer, full width (bands 2 and 3 live inside + // the hosted ConnectApp: transcript above, input box at the bottom). + // overflow=hidden clips a mis-estimated transcript slice instead of + // letting the frame outgrow the terminal (which scrolls Ink's render + // region and smears stale rows on every hop). The meta line is + // hidden here — the header renders it. + + + + ) + } + } + + // ---- band 4: the session nav ---- + // A horizontal bar of fixed-width cells (status dot + description over a + // dim age line), windowed around the highlight. The pinned "+ New" cell + // leads. + const cellCapacity = Math.max(1, Math.floor((termCols - 10) / NAV_ITEM_W)) + const selectedRowIdx = Math.max(0, rows.findIndex((s) => s.id === selected)) + const win = sidebarSlice(rows.length, cellCapacity, selectedRowIdx) + const navFocused = focus === 'nav' + const nav = ( + + {'─'.repeat(Math.max(0, termCols))} + + {/* The pinned new-session cell. */} + + + {' '} + {selected === 'new' && navFocused ? ( + + {` ${SELECTION_GLYPH} New `} + + ) : ( + + + New + + )} + + + + {win.start > 0 && ( + + + + )} + {rows.slice(win.start, win.end).map((s) => { + const word = rowStatusWord(s) + const g = rowGlyph(word) + const cursorHere = selected === s.id && navFocused + const isOpen = mainPane.type === 'chat' && mainPane.sessionId === s.id + const desc = rowDescription(s) + const descW = NAV_ITEM_W - 4 + return ( + + + {' '} + + {cursorHere ? SELECTION_GLYPH : g.glyph} + {' '} + + {cursorHere + ? ` ${desc.slice(0, Math.max(4, descW - 2))} ` + : desc.slice(0, Math.max(4, descW))} + + + + {' '} + + {shortAge(lastEventAt(s))} · {s.source === 'laptop' ? 'laptop' : 'cloud'} + {attention.has(s.id) ? ' · needs you' : ''} + + + + ) + })} + {win.end < rows.length && ( + + › {rows.length - win.end} more + + )} + {rows.length === 0 && ( + {polledOnce ? ' no sessions yet' : ' loading sessions…'} + )} + + + {navFocused + ? ' ←→ move · enter open · n new · ↑ chat · q quit' + : ' ↓/esc: sessions'} + + + ) + + return ( + + {header} + {main} + {nav} + + ) +} + +// The header's live meta for the focused session: status · cost · model · +// id (hyperlinked) · version, derived from the session's transcript store +// so it ticks with the stream exactly like the old in-chat footer. +function useHeaderMeta( + entry: ChatEntry | undefined, + mainPane: MainPane, + appBase: string, + customerLogin: string, +): string | null { + const subscribe = useCallback( + (cb: () => void) => (entry ? entry.store.subscribe(cb) : () => {}), + [entry], + ) + const snapshot = useSyncExternalStore( + subscribe, + () => (entry ? entry.store.getSnapshot() : null), + () => (entry ? entry.store.getSnapshot() : null), + ) + if (mainPane.type !== 'chat' || !entry) return null + const session = snapshot?.session as AgentSessionWire | undefined | null + const statusWord = session ? sessionStatusWord(session) : 'starting' + const costUsd = session + ? usdNumberFromMillicents( + session.cost_tokens + + session.cost_sandbox_cpu + + session.cost_sandbox_memory + + session.cost_fee, + ) + : 0 + return [ + `${statusWord} · $${costUsd.toFixed(2)}`, + ...(entry.model ? [entry.model] : []), + hyperlink( + sessionUrl(appBase, customerLogin, mainPane.sessionId), + `${mainPane.sessionId.slice(0, 20)}…`, + ), + ...(entry.configName ? [entry.configName] : []), + `v${VERSION}`, + ].join(' · ') +} + +// Swallows everything except esc and ← — the keyboard owner for placeholder +// panes, either of which hands focus back to the sidebar. +function EscOnlyInput({ + active, + rawMode, + onEsc, +}: { + active: boolean + rawMode: boolean + onEsc: () => void +}): React.ReactElement | null { + useInput( + (_ch, key) => { + if (key.escape || key.leftArrow) onEsc() + }, + { isActive: active && rawMode }, + ) + return null +} + +// One row of the new-session form: a label + the picked value(s), opened +// into its option list with →/enter (the dashboard composer's selects, +// terminal-shaped). Repositories multi-select; the others pick one. +type PickerRow = { key: 'config' | 'model' | 'repo'; label: string } +const PICKER_ROWS: readonly PickerRow[] = [ + { key: 'repo', label: 'Repository' }, + { key: 'config', label: 'Agent' }, + { key: 'model', label: 'Model' }, +] + +// The new-session pane, mirroring the dashboard's home composer +// (app.ellipsis.dev/[login]): a centered "What would you like to do?" +// heading a third of the way down, then ONE box — a single-line prompt +// input with the Agent / Model / Repository selects as compact chips along +// the box's bottom edge. ↓ from the prompt reaches the chip row, ←/→ move +// between chips, enter/↓ opens a chip's option list ([x] marks the pick). +// Inside an open list ↑/↓ walk, → (or enter/space) activates the +// highlighted option, ← (or esc) backs out of the subtree unchanged. +// "Default" everywhere means the server resolves it (defaults ladder, +// DEFAULT_AGENT_MODEL, the detected repo). Esc — or ← at the prompt's left +// edge / the leftmost chip — hands focus to the sidebar. +function NewSessionPane({ + width, + height, + focused, + starting, + error, + configs, + repos, + onSubmit, + onLeave, + rawMode, +}: { + width: number + height: number + focused: boolean + starting: boolean + error: string | null + // null while loading; [] when the account has none / the fetch failed. + configs: SavedAgentConfig[] | null + repos: string[] | null + onSubmit: ( + text: string, + choices: { configId: string | null; model: string | null; repos: string[] }, + ) => void + onLeave: () => void + rawMode: boolean +}): React.ReactElement { + const [text, setText] = useState('') + const [cursor, setCursor] = useState(0) + // Where the form cursor is: the prompt line, or one of the option rows + // beneath it (by PICKER_ROWS index). + const [row, setRow] = useState<'prompt' | number>('prompt') + // Single-pick indices; 0 is always "Default" (server-resolved). + const [configIdx, setConfigIdx] = useState(0) + const [modelIdx, setModelIdx] = useState(0) + // The multi-select repository set ("owner/name" full names). Empty = + // Default (the detected repo, server-resolved). + const [repoSel, setRepoSel] = useState>(new Set()) + // The open row's dropdown state: which picker is open and where its + // highlight sits. null = no subtree open. + const [openPicker, setOpenPicker] = useState<{ key: PickerRow['key']; hover: number } | null>( + null, + ) + + const configOptions = useMemo( + () => [ + { id: null as string | null, label: 'Default' }, + ...(configs ?? []).map((c) => ({ id: c.id as string | null, label: configDisplayName(c) })), + ], + [configs], + ) + const modelOptions = COMPOSER_MODELS + const repoOptions = useMemo( + () => [ + { id: null as string | null, label: 'Default (detected)' }, + ...(repos ?? []).map((r) => ({ id: r as string | null, label: r })), + ], + [repos], + ) + const optionsFor = (key: PickerRow['key']) => + key === 'config' ? configOptions : key === 'model' ? modelOptions : repoOptions + // Whether an option is currently picked. Repo is a multi-select (id null + // = the Default entry, picked while the set is empty); the others match + // their single index. + const isPicked = (key: PickerRow['key'], at: number): boolean => { + if (key === 'repo') { + const id = repoOptions[at]?.id + return id === null ? repoSel.size === 0 : repoSel.has(id ?? '') + } + const idx = key === 'config' ? configIdx : modelIdx + return at === Math.min(idx, optionsFor(key).length - 1) + } + // Activating an option: single-pickers pick and close; the repo list + // TOGGLES the entry ([x]↔[ ]) and stays open so several can be checked + // (Default clears the set). + const activate = (key: PickerRow['key'], at: number): void => { + if (key === 'config') { + setConfigIdx(at) + setOpenPicker(null) + } else if (key === 'model') { + setModelIdx(at) + setOpenPicker(null) + } else { + const id = repoOptions[at]?.id + if (id == null) setRepoSel(new Set()) + else { + setRepoSel((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + } + } + + const submit = (): void => { + const trimmed = text.trim() + if (!trimmed) return + onSubmit(trimmed, { + configId: configOptions[Math.min(configIdx, configOptions.length - 1)]?.id ?? null, + model: modelOptions[Math.min(modelIdx, modelOptions.length - 1)]?.id ?? null, + repos: [...repoSel], + }) + } + + useInput( + (ch, key) => { + // An open dropdown is a modal subtree: ↑/↓ walk the options, + // → (or enter/space) activates the highlighted one — single-pickers + // close on pick, the repo list toggles and stays open for more — + // ← (or esc) backs out of the subtree. + if (openPicker !== null) { + const options = optionsFor(openPicker.key) + if (key.escape || key.leftArrow) { + setOpenPicker(null) + return + } + if (key.upArrow) { + setOpenPicker((p) => p && { ...p, hover: Math.max(0, p.hover - 1) }) + return + } + if (key.downArrow) { + setOpenPicker((p) => p && { ...p, hover: Math.min(options.length - 1, p.hover + 1) }) + return + } + if (key.rightArrow || key.return || ch === ' ') { + activate(openPicker.key, Math.min(openPicker.hover, options.length - 1)) + return + } + return + } + if (key.escape) { + onLeave() + return + } + if (starting) return + if (row !== 'prompt') { + // The option rows under the prompt box, walked vertically: ↑/↓ move + // between them (↑ off the first returns to the prompt, ↓ off the + // last continues to the session nav), →/enter opens the row's list, + // ← leaves for the nav, typing returns to the prompt. + if (key.upArrow) { + if (row === 0) setRow('prompt') + else setRow(row - 1) + return + } + if (key.downArrow) { + if (row >= PICKER_ROWS.length - 1) onLeave() + else setRow(row + 1) + return + } + if (key.return || key.rightArrow) { + setOpenPicker({ key: PICKER_ROWS[row].key, hover: 0 }) + return + } + if (key.leftArrow) { + onLeave() + return + } + if (ch && !key.ctrl && !key.meta) { + setRow('prompt') + setText((t) => t.slice(0, cursor) + ch + t.slice(cursor)) + setCursor((c) => c + ch.length) + } + return + } + if (key.return) { + submit() + return + } + if (key.downArrow) { + setRow(0) + return + } + if (key.leftArrow) { + if (cursor === 0) { + onLeave() + return + } + setCursor((c) => Math.max(0, c - 1)) + return + } + if (key.rightArrow) { + setCursor((c) => Math.min(text.length, c + 1)) + return + } + if (key.backspace || key.delete) { + if (cursor > 0) { + setText((t) => t.slice(0, cursor - 1) + t.slice(cursor)) + setCursor((c) => c - 1) + } + return + } + if (key.ctrl || key.meta || key.tab || key.upArrow) return + if (ch) { + setText((t) => t.slice(0, cursor) + ch + t.slice(cursor)) + setCursor((c) => c + ch.length) + } + }, + { isActive: focused && rawMode }, + ) + + // The summary shown on a row: the single pick's label, or the checked + // repo set joined (Default when empty). + const rowValue = (key: PickerRow['key']): string => { + if (key === 'repo') { + if (repos === null) return 'loading…' + return repoSel.size === 0 ? 'Default (detected)' : [...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' + } + + // How many dropdown options fit in the space above the input: the pane + // minus the heading, notices, input box, rows, and hints (~13 rows). + const dropdownCapacity = Math.max(3, height - 13) + const open = openPicker + const openOptions = open ? optionsFor(open.key) : [] + const openHover = open ? Math.min(open.hover, openOptions.length - 1) : 0 + const win = open + ? sidebarSlice(openOptions.length, dropdownCapacity, openHover) + : { start: 0, end: 0 } + + return ( + // Bottom-docked, mirroring the chat layout: the heading floats centered + // in the empty space (equal spacers above and below it) while the input + // + option rows pin to the bottom edge (just above the session nav), + // where they NEVER move — an open dropdown expands upward instead of + // pushing the input around. + + + + What would you like to do? + + + {error && ✗ {error}} + {starting && ✻ Starting session…} + {/* The open row's option list, a popup ABOVE the input (the input is + docked; menus open upward like any bottom bar's): ↑/↓ walk, + → activates, ← backs out. [x] marks the pick. */} + {open && ( + + {PICKER_ROWS.find((r) => r.key === open.key)?.label}: + {win.start > 0 && … {win.start} more} + {openOptions.slice(win.start, win.end).map((opt, j) => { + const at = win.start + j + const hovered = at === openHover + const picked = isPicked(open.key, at) + return ( + + {hovered ? SELECTION_GLYPH : ' '}{' '} + + {hovered + ? ` [${picked ? 'x' : ' '}] ${opt.label} ` + : `[${picked ? 'x' : ' '}] ${opt.label}`} + + + ) + })} + {win.end < openOptions.length && ( + … {openOptions.length - win.end} more + )} + + {open.key === 'repo' ? '→ toggle · ← done' : '→ select · ← back'} + + + )} + {/* The prompt input — the SAME box shape as the chat composer: a + 3-row area framed by full-width top and bottom rules, no side + borders. */} + + + + {SELECTION_GLYPH}{' '} + + {text.slice(0, cursor)} + {focused && row === 'prompt' && !starting && openPicker === null && ( + {cursor < text.length ? text[cursor] : ' '} + )} + {cursor < text.length + ? text.slice( + cursor + + (focused && row === 'prompt' && !starting && openPicker === null ? 1 : 0), + ) + : ''} + {text === '' && (focused && row === 'prompt' ? '' : ' ')} + {text === '' && Describe the task…} + + + {/* The run controls, one row each below the input: Repository, Agent, + Model. →/enter opens a row's option list ABOVE the input (the + input never moves); repositories multi-select ([x] toggles), the + others pick one. */} + {PICKER_ROWS.map((r, i) => { + const active = focused && openPicker === null && row === i + const isOpen = openPicker?.key === r.key + return ( + + {' '} + + {active || isOpen ? SELECTION_GLYPH : ' '} + {' '} + {r.label}: + + {active ? ` ${rowValue(r.key)} ` : rowValue(r.key)} + + {active ? (→: choose) : null} + + ) + })} + {/* Always rendered (blank while a dropdown is open) so the docked + input never shifts by the hint row's height. */} + + {open ? ' ' : ' enter: start · ↓ options · esc: sessions'} + + + ) +} diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx new file mode 100644 index 0000000..2b4bb6b --- /dev/null +++ b/src/ui/launch.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { render } from 'ink' +import { ApiClient } from '../lib/api' +import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' +import { repoFromCwd } from '../lib/laptop' +import { makeOpenSocket, resolveWsBase } from '../lib/stream' +import type { StartAgentSessionRequest } from '../lib/types' +import { SessionsApp } from './SessionsApp' + +// Launches the multi-session UI (sidebar + chat) — the shared destination of +// a bare `agent`, `agent "prompt"`, and `agent session connect `. The +// caller decides what the main pane opens on (a focused session or the +// new-session composer) and how composer-spawned sessions start. + +export interface SessionsUiOptions { + // Focus this session's chat on open; omit for the new-session composer. + initialSessionId?: string + // The start response's resolved config name + a caveat for that chat. + initialConfigName?: string + initialNotice?: string + // Builds the POST /v1/sessions request for a composer-spawned session; the + // typed text rides as the prompt. Entry points bake their flags in here. + buildStartRequest: (prompt: string) => StartAgentSessionRequest +} + +// Whether this invocation can host the interactive multi-session UI at all: +// it needs a real TTY on both ends (raw-mode keyboard + a sized window). +// Headless callers (scripts, sandboxes, --no-input) keep the solo renderer. +export function canHostSessionsUi(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY) +} + +// The composer-spawned session's start request when the entry point brings +// no flags of its own (connect, and the composer inside a prompt-shorthand +// UI): default-config resolution with the detected repository, exactly like +// a bare `agent` start. +export function defaultStartRequest(prompt: string): StartAgentSessionRequest { + const req: StartAgentSessionRequest = { prompt } + const contextRepo = repoFromCwd(process.cwd()) + if (contextRepo) req.repository = contextRepo + return req +} + +export async function runSessionsUi(options: SessionsUiOptions): Promise { + const api = new ApiClient() + const token = requireToken() + const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) + const me = await api.whoami() + + // Start at the top of a fresh window: scroll whatever is on screen into + // scrollback, then home the cursor (same dance as the solo connect). + if (process.stdout.isTTY) { + process.stdout.write('\n'.repeat(process.stdout.rows ?? 24) + '\x1b[H') + } + const app = render( + React.createElement(SessionsApp, { + api, + openSocket, + appBase: resolveAppBase(), + customerLogin: me.customer_login, + authorId: me.gh_user?.id ?? null, + initialSessionId: options.initialSessionId, + initialConfigName: options.initialConfigName, + initialNotice: options.initialNotice, + buildStartRequest: options.buildStartRequest, + }), + ) + // Same revoked-TTY guard as the solo connect: when the terminal is torn + // down abruptly, stdin's fd stays open but polls fire forever; unmount on + // the stream's failure events and on reparenting to init. + const detach = (): void => app.unmount() + process.stdin.on('error', detach) + process.stdin.on('end', detach) + process.stdin.on('close', detach) + const orphanWatch = setInterval(() => { + if (process.ppid === 1) app.unmount() + }, 2000) + orphanWatch.unref() + try { + await app.waitUntilExit() + } finally { + clearInterval(orphanWatch) + process.stdin.off('error', detach) + process.stdin.off('end', detach) + process.stdin.off('close', detach) + } +} diff --git a/test/sessions.test.ts b/test/sessions.test.ts new file mode 100644 index 0000000..fe9e4d9 --- /dev/null +++ b/test/sessions.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest' +import { + attentionFlip, + connectability, + isActiveStatusWord, + isOpenConversation, + lastEventAt, + rowDescription, + rowGlyph, + rowStatusWord, + shortAge, + sidebarSlice, + sortSidebarSessions, +} from '../src/lib/sessions' +import type { AgentSession } from '../src/lib/types' + +function session(overrides: Partial): AgentSession { + return { + id: 'session_1', + customer_id: 'c1', + created_at: '2026-07-07T00:00:00Z', + updated_at: '2026-07-07T00:00:00Z', + status: 'running', + status_reason: null, + agent_config_id: null, + cost_tokens: 0, + cost_sandbox_cpu: 0, + cost_sandbox_memory: 0, + cost_fee: 0, + tokens_total: 0, + metadata: {}, + ...overrides, + } +} + +describe('connectability', () => { + it('opens keyed live sessions for sending', () => { + expect(connectability(session({ session_key: 'api:x', session_state: 'running' }))).toEqual({ + canSend: true, + }) + }) + + it('is watch-only for single-shot sessions', () => { + const c = connectability(session({ session_key: null })) + expect(c.canSend).toBe(false) + expect(c.reason).toMatch(/single-shot/) + }) + + it('is watch-only for closed conversations', () => { + const c = connectability(session({ session_key: 'api:x', session_state: 'closed' })) + expect(c.canSend).toBe(false) + expect(c.reason).toMatch(/closed/) + }) +}) + +describe('rowStatusWord / rowGlyph', () => { + it('prefers the surface projection over the raw status', () => { + const s = session({ + status: 'running', + surface: { session: 'alive', run: 'working', status: 'waiting' }, + }) + expect(rowStatusWord(s)).toBe('waiting') + }) + + it('falls back to the raw status without a surface', () => { + expect(rowStatusWord(session({ status: 'completed' }))).toBe('completed') + }) + + it('is always a dot — status is told by color, the arrow means selection', () => { + for (const word of ['working', 'waiting', 'sleeping', 'failed', 'stopped', 'completed']) { + expect(rowGlyph(word).glyph).toBe('●') + } + }) + + it('colors in-flight yellow, waiting cyan, sleeping dim', () => { + expect(rowGlyph('working')).toEqual({ glyph: '●', color: 'yellow', dim: false }) + expect(isActiveStatusWord('working')).toBe(true) + expect(rowGlyph('waiting').color).toBe('cyan') + expect(rowGlyph('sleeping')).toEqual({ glyph: '●', dim: true }) + }) + + it('colors failures red and settles the rest as done green', () => { + expect(rowGlyph('failed').color).toBe('red') + expect(rowGlyph('error')).toEqual({ glyph: '●', color: 'red', dim: false }) + expect(rowGlyph('stopped')).toEqual({ glyph: '●', color: 'red', dim: true }) + expect(rowGlyph('completed').color).toBe('green') + expect(rowGlyph('closed').color).toBe('green') + }) +}) + +describe('rowDescription', () => { + it('prefers the live summary, collapsed to one line', () => { + const s = session({ live_summary: 'fixing the\n webhook tests', prompt: 'do a thing' }) + expect(rowDescription(s)).toBe('fixing the webhook tests') + }) + + it('falls back to the prompt, then the source', () => { + expect(rowDescription(session({ prompt: 'fix the tests' }))).toBe('fix the tests') + expect(rowDescription(session({ source: 'react' }))).toBe('react session') + expect(rowDescription(session({}))).toBe('session') + }) + + it('ignores whitespace-only summaries', () => { + expect(rowDescription(session({ live_summary: ' \n ', prompt: 'p' }))).toBe('p') + }) +}) + +describe('lastEventAt / shortAge', () => { + it('prefers last_activity_at, then last_message_at, then updated_at', () => { + expect( + lastEventAt( + session({ last_activity_at: 'A', last_message_at: 'B', updated_at: 'C' } as never), + ), + ).toBe('A') + expect(lastEventAt(session({ last_message_at: 'B', updated_at: 'C' } as never))).toBe('B') + expect(lastEventAt(session({ updated_at: 'C' }))).toBe('C') + }) + + it('renders compact ages and never goes negative', () => { + const now = new Date('2026-07-23T12:00:00Z') + expect(shortAge('2026-07-23T11:59:48Z', now)).toBe('12s ago') + expect(shortAge('2026-07-23T11:58:00Z', now)).toBe('2m ago') + expect(shortAge('2026-07-23T09:00:00Z', now)).toBe('3h ago') + expect(shortAge('2026-07-18T12:00:00Z', now)).toBe('5d ago') + expect(shortAge('2026-07-23T12:00:05Z', now)).toBe('0s ago') + }) +}) + +describe('sortSidebarSessions', () => { + it('puts open conversations first, each group newest-event first', () => { + const open1 = session({ id: 'open1', session_state: 'idle', updated_at: '2026-07-23T10:00:00Z' }) + const open2 = session({ + id: 'open2', + session_state: 'running', + updated_at: '2026-07-23T11:00:00Z', + }) + const closed = session({ + id: 'closed', + session_state: 'closed', + updated_at: '2026-07-23T12:00:00Z', + }) + const done = session({ id: 'done', status: 'completed', updated_at: '2026-07-23T13:00:00Z' }) + expect(sortSidebarSessions([closed, open1, done, open2]).map((s) => s.id)).toEqual([ + 'open2', + 'open1', + 'done', + 'closed', + ]) + }) + + it('treats non-terminal stateless sessions as open', () => { + expect(isOpenConversation(session({ status: 'running' }))).toBe(true) + expect(isOpenConversation(session({ status: 'completed' }))).toBe(false) + }) +}) + +describe('attentionFlip', () => { + it('flags active → waiting transitions', () => { + expect(attentionFlip('working', 'waiting')).toBe(true) + expect(attentionFlip('starting', 'idle')).toBe(true) + }) + + it('ignores first sightings, active continuations, and terminal flips', () => { + expect(attentionFlip(undefined, 'waiting')).toBe(false) + expect(attentionFlip('working', 'working')).toBe(false) + expect(attentionFlip('waiting', 'waiting')).toBe(false) + expect(attentionFlip('working', 'completed')).toBe(false) + }) +}) + +describe('sidebarSlice', () => { + it('shows everything when it fits', () => { + expect(sidebarSlice(3, 10, 0)).toEqual({ start: 0, end: 3 }) + }) + + it('windows around the selection when it overflows', () => { + expect(sidebarSlice(20, 6, 0)).toEqual({ start: 0, end: 6 }) + expect(sidebarSlice(20, 6, 10)).toEqual({ start: 7, end: 13 }) + expect(sidebarSlice(20, 6, 19)).toEqual({ start: 14, end: 20 }) + }) +})