/** * @fileoverview Pure state, classification and grouping for the TUI dashboard. * * Classification speaks the web UI's language on purpose (red blocked, yellow * waiting, green working, muted idle), because a user who has both surfaces * open must never have to translate between them. The inputs are the ones the * server already computes: a unified-list row and, when the session is blocked, * the approvals-inbox item that blocks it. Nothing here screen-scrapes. * * Selection is tracked by session id, never by row index: rows re-sort under * the cursor constantly (a session starts working, an approval lands), and an * index-tracked cursor would silently move the selection to a different * session between two keystrokes. * * PURE: no IO, no timers, no `process.*`. The store mutates its own state and * nothing else. * * @module tui/tui-model */ import type { ApprovalItem } from '../web/approval-inbox.js'; import type { TuiConfirmState, TuiConnectionStatus, TuiGroup, TuiGroupKey, TuiHeaderInfo, TuiMessage, TuiPreview, TuiRenderModel, TuiRow, TuiSessionRow, TuiSessionState, TuiUiMode, } from './tui-types.js'; /** How many history rows the RECENT group shows before it stops being a dashboard. */ export const DEFAULT_RECENT_LIMIT = 8; export const GROUP_ORDER: readonly TuiGroupKey[] = ['needs-you', 'working', 'idle', 'recent']; export const GROUP_LABELS: Record = { 'needs-you': 'NEEDS YOU', working: 'WORKING', idle: 'IDLE', recent: 'RECENT', }; const STATE_GROUP: Record = { 'blocked-question': 'needs-you', 'blocked-permission': 'needs-you', waiting: 'needs-you', working: 'working', idle: 'idle', recent: 'recent', }; /** A row is live when the unified merge saw it in the in-memory session map. */ export function isLiveRow(session: TuiSessionRow): boolean { return Array.isArray(session.sources) && session.sources.includes('live'); } /** * Classify one row. * * Order matters and mirrors `_mobileOverviewState()` in the web UI: a pending * prompt outranks everything (it is literally blocking the agent), and it * outranks a stale `busy` status because the hook is the newer signal. An * errored session has no state of its own here and joins the waiting tier, * since it is equally something only a human can clear. */ export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState { if (!isLiveRow(session)) return 'recent'; if (approval) { if (approval.kind === 'permission') return 'blocked-permission'; if (approval.kind === 'question') return 'blocked-question'; return 'waiting'; } if (session.status === 'error') return 'waiting'; if (session.isWorking === true || session.status === 'busy') return 'working'; return 'idle'; } /** * Epoch ms the session entered its current state, which is what the intra-group * ordering sorts on. 0 when nothing usable is known. * * A WORKING pane repaints about once a second, so its `lastActivityAt` is * always "now" and would report every running turn as freshly started; the * turn's own start is the pane's last Enter. */ export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number { if (approval) return approval.createdAt; if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0; return session.lastActivityAt ?? session.createdAt ?? 0; } /** Classify a batch of rows against the pending approvals, keyed by session id. */ export function buildRows( sessions: readonly TuiSessionRow[], approvals: ReadonlyMap = new Map() ): TuiRow[] { return sessions.map((session) => { const approval = approvals.get(session.sessionId); const state = classifySession(session, approval); const row: TuiRow = { session, state, group: STATE_GROUP[state], since: stateSince(state, session, approval), }; if (approval) row.approval = approval; return row; }); } function compareIds(a: TuiRow, b: TuiRow): number { if (a.session.sessionId < b.session.sessionId) return -1; if (a.session.sessionId > b.session.sessionId) return 1; return 0; } /** Longest first: the oldest anchor wins, and an unknown anchor sorts last. */ function compareLongestFirst(a: TuiRow, b: TuiRow): number { const left = a.since || Number.MAX_SAFE_INTEGER; const right = b.since || Number.MAX_SAFE_INTEGER; return left !== right ? left - right : compareIds(a, b); } /** Newest first: the freshest anchor wins, and an unknown anchor sorts last. */ function compareNewestFirst(a: TuiRow, b: TuiRow): number { const left = a.since || 0; const right = b.since || 0; return left !== right ? right - left : compareIds(a, b); } export interface GroupOptions { /** RECENT is a tail, not a list: everything past this is dropped. */ recentLimit?: number; } /** * Split classified rows into the four display groups. * * Always returns all four in display order (empty ones included) so callers * never have to guess the shape; the renderer skips the empty ones. * * NEEDS YOU and WORKING are ordered by how long they have been in that state * (longest first: the thing that has waited longest for you is the thing to * look at). IDLE and RECENT are ordered by recency, newest first. */ export function groupSessions(rows: readonly TuiRow[], options: GroupOptions = {}): TuiGroup[] { const recentLimit = Math.max(0, Math.floor(options.recentLimit ?? DEFAULT_RECENT_LIMIT)); const buckets: Record = { 'needs-you': [], working: [], idle: [], recent: [], }; for (const row of rows) buckets[row.group].push(row); buckets['needs-you'].sort(compareLongestFirst); buckets.working.sort(compareLongestFirst); buckets.idle.sort(compareNewestFirst); buckets.recent.sort(compareNewestFirst); buckets.recent = buckets.recent.slice(0, recentLimit); return GROUP_ORDER.map((key) => ({ key, label: GROUP_LABELS[key], rows: buckets[key] })); } /** The cursor's list: group headers are chrome, only sessions are selectable. */ export function flattenRows(groups: readonly TuiGroup[]): TuiRow[] { const rows: TuiRow[] = []; for (const group of groups) rows.push(...group.rows); return rows; } /** * Fold an incoming row into a known one. Defined fields win, `undefined` never * clobbers (a live SSE payload carries no transcript fields, a unified refresh * carries no token counters), but a non-empty `sources` list REPLACES rather * than unions: a session that ended must be able to lose its `live` source and * fall to RECENT. */ export function mergeSessionRow(existing: TuiSessionRow, incoming: TuiSessionRow): TuiSessionRow { const merged: TuiSessionRow = { ...existing }; for (const [key, value] of Object.entries(incoming)) { if (value === undefined) continue; (merged as unknown as Record)[key] = value; } merged.sources = incoming.sources?.length ? [...incoming.sources] : [...(existing.sources ?? [])]; return merged; } /** * The dashboard's state. Update methods mutate in place (one store per TUI * process, no subscribers) and every derived view is recomputed from scratch, * which keeps "what is on screen" a pure function of the stored facts. */ export class TuiModelStore implements TuiRenderModel { private sessionsById = new Map(); private approvalsBySession = new Map(); selectedId: string | null = null; connection: TuiConnectionStatus = 'connected'; mode: TuiUiMode = 'list'; header: TuiHeaderInfo = {}; preview: TuiPreview | null = null; message: TuiMessage | null = null; confirm: TuiConfirmState | null = null; recentLimit: number; constructor(options: GroupOptions = {}) { this.recentLimit = Math.max(0, Math.floor(options.recentLimit ?? DEFAULT_RECENT_LIMIT)); } // ── Data ─────────────────────────────────────────────────────────────────── upsertSession(session: TuiSessionRow): void { this.mutate(() => { const existing = this.sessionsById.get(session.sessionId); this.sessionsById.set(session.sessionId, existing ? mergeSessionRow(existing, session) : { ...session }); }); } removeSession(sessionId: string): void { this.mutate(() => { this.sessionsById.delete(sessionId); this.approvalsBySession.delete(sessionId); }); } /** Full refresh (a `GET /api/sessions/unified` poll): the server is authoritative. */ replaceSessions(sessions: readonly TuiSessionRow[]): void { this.mutate(() => { this.sessionsById.clear(); for (const session of sessions) this.sessionsById.set(session.sessionId, { ...session }); }); } setApprovals(items: readonly ApprovalItem[]): void { this.mutate(() => { this.approvalsBySession.clear(); // One active item per session is an inbox invariant; the newest wins if // that ever stops being true. for (const item of items) this.approvalsBySession.set(item.sessionId, item); }); } approvalFor(sessionId: string): ApprovalItem | undefined { return this.approvalsBySession.get(sessionId); } sessions(): TuiSessionRow[] { return [...this.sessionsById.values()]; } // ── Chrome ───────────────────────────────────────────────────────────────── setConnection(status: TuiConnectionStatus): void { this.connection = status; } setHeader(header: TuiHeaderInfo): void { this.header = { ...this.header, ...header }; } setPreview(preview: TuiPreview | null): void { this.preview = preview; } setMode(mode: TuiUiMode): void { this.mode = mode; } setMessage(message: TuiMessage | null): void { this.message = message; this.mode = message ? 'message' : 'list'; } /** Arm the typed-name confirmation for `x` (kill). */ beginConfirmKill(row: TuiRow): void { this.confirm = { sessionId: row.session.sessionId, name: row.session.name ?? row.session.sessionId.slice(0, 8), typed: '', }; this.mode = 'confirm-kill'; } setConfirmInput(typed: string): void { if (this.confirm) this.confirm = { ...this.confirm, typed }; } /** Does the typed text authorize the kill? Exact match on the name shown. */ confirmSatisfied(): boolean { return this.confirm !== null && this.confirm.typed.trim() === this.confirm.name; } /** Drop whatever overlay owns the keyboard and go back to the list. */ closeOverlay(): void { this.confirm = null; this.message = null; this.mode = 'list'; } // ── Derived views ────────────────────────────────────────────────────────── groups(): TuiGroup[] { return groupSessions(buildRows(this.sessions(), this.approvalsBySession), { recentLimit: this.recentLimit }); } rows(): TuiRow[] { return flattenRows(this.groups()); } get sessionCount(): number { let count = 0; for (const session of this.sessionsById.values()) if (isLiveRow(session)) count++; return count; } // ── Cursor ───────────────────────────────────────────────────────────────── selectedSession(): TuiRow | null { if (!this.selectedId) return null; return this.rows().find((row) => row.session.sessionId === this.selectedId) ?? null; } /** Select a session by id. Returns false when it is not on screen. */ select(sessionId: string): boolean { if (!this.rows().some((row) => row.session.sessionId === sessionId)) return false; this.selectedId = sessionId; return true; } /** Move by `delta` rows, skipping group headers and wrapping at both ends. */ moveCursor(delta: number): void { const rows = this.rows(); if (rows.length === 0) { this.selectedId = null; return; } const current = this.indexOfSelected(rows); if (current < 0) { this.selectedId = rows[delta >= 0 ? 0 : rows.length - 1].session.sessionId; return; } const step = Math.trunc(delta); const next = (((current + step) % rows.length) + rows.length) % rows.length; this.selectedId = rows[next].session.sessionId; } /** The 1-9 jump: `n` is the 1-based position in the flattened list. */ cursorToIndex(n: number): boolean { const rows = this.rows(); const index = Math.trunc(n) - 1; if (index < 0 || index >= rows.length) return false; this.selectedId = rows[index].session.sessionId; return true; } private indexOfSelected(rows: readonly TuiRow[] = this.rows()): number { if (!this.selectedId) return -1; return rows.findIndex((row) => row.session.sessionId === this.selectedId); } /** * Run a data mutation and keep the cursor sane afterwards: the selected * session stays selected wherever it moved to, and a session that vanished * hands the cursor to whatever now occupies its place. */ private mutate(apply: () => void): void { const previousIndex = this.indexOfSelected(); apply(); const rows = this.rows(); if (rows.length === 0) { this.selectedId = null; return; } if (this.selectedId !== null && rows.some((row) => row.session.sessionId === this.selectedId)) return; const index = Math.min(Math.max(previousIndex, 0), rows.length - 1); this.selectedId = rows[index].session.sessionId; } } export function createTuiModel(options: GroupOptions = {}): TuiModelStore { return new TuiModelStore(options); }