From aa2deea73ed8b6e4a117223769616161da041df0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:54:53 +0200 Subject: [PATCH] feat: add the TUI session model, classification and cursor Rows are the ones GET /api/sessions/unified already returns and blocked states are the items the approvals inbox already parsed, both imported as types only so a CLI process pulls in neither the server nor node-pty. Classification speaks the web UI's language (red blocked, yellow waiting, green working) so a user with both surfaces open never has to translate between them. Groups order by how long a session has been in its state, which is why WORKING anchors on the pane's last Enter: a working pane repaints about once a second, so its last-activity stamp always says "now". Selection is tracked by session id, never by row index: rows re-sort under the cursor whenever a session starts working or an approval lands, and an index-tracked cursor would quietly move the selection to another session between two keystrokes. Co-Authored-By: Claude Fable 5 --- src/tui/tui-model.ts | 389 +++++++++++++++++++++++++++++++++++++ src/tui/tui-types.ts | 125 ++++++++++++ test/tui/tui-model.test.ts | 283 +++++++++++++++++++++++++++ 3 files changed, 797 insertions(+) create mode 100644 src/tui/tui-model.ts create mode 100644 src/tui/tui-types.ts create mode 100644 test/tui/tui-model.test.ts diff --git a/src/tui/tui-model.ts b/src/tui/tui-model.ts new file mode 100644 index 00000000..81e94ca6 --- /dev/null +++ b/src/tui/tui-model.ts @@ -0,0 +1,389 @@ +/** + * @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); +} diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts new file mode 100644 index 00000000..4609b8b0 --- /dev/null +++ b/src/tui/tui-types.ts @@ -0,0 +1,125 @@ +/** + * @fileoverview Shared types for the `codeman tui` pure core. + * + * The TUI is a client of the server, never a second brain: its rows are the + * rows `GET /api/sessions/unified` already returns (`UnifiedSessionItem`) and + * its blocked states are the items `GET /api/approvals` already parsed + * (`ApprovalItem`). Both are imported as TYPES only, so nothing here pulls the + * server, node-pty or the utils barrel into a CLI process. + * + * Everything in `src/tui/*` except `tui-app.ts` / `tui-client.ts` is pure: + * deterministic outputs from inputs, no `process.*`, no timers, no IO. + * + * @module tui/tui-types + */ + +import type { UnifiedSessionItem } from '../services/unified-session-service.js'; +import type { ApprovalItem } from '../web/approval-inbox.js'; + +/** + * A unified-list row plus the few live-only extras the dashboard shows. + * + * The unified list is the spine (it is the only source that carries history + * rows), but it has no token counters and no turn-start stamp, so the client + * merges those from the live session payload (`GET /api/sessions` / + * `session_updated` SSE) when a row is live. History rows simply lack them. + */ +export interface TuiSessionRow extends UnifiedSessionItem { + /** + * Wall-clock ms of the pane's last Enter (`SessionState.lastSubmitAt`). The + * only usable "working since" anchor: a working pane repaints about once a + * second, so its `lastActivityAt` is always "now". + */ + lastSubmitAt?: number; + inputTokens?: number; + outputTokens?: number; +} + +/** + * Row state, in the web UI's vocabulary so both surfaces read the same. + * + * There is deliberately no `error` member: an errored session is something a + * human has to look at, so it classifies as `waiting` and lands in NEEDS YOU + * rather than growing a fifth color nobody designed. + */ +export type TuiSessionState = 'blocked-question' | 'blocked-permission' | 'waiting' | 'working' | 'idle' | 'recent'; + +/** The four display groups, in display order. */ +export type TuiGroupKey = 'needs-you' | 'working' | 'idle' | 'recent'; + +/** A classified session: what the cursor moves over and the renderer paints. */ +export interface TuiRow { + session: TuiSessionRow; + state: TuiSessionState; + group: TuiGroupKey; + /** The pending prompt that blocks this session, when it has one. */ + approval?: ApprovalItem; + /** Epoch ms the session entered `state`; the intra-group sort key. 0 when unknown. */ + since: number; +} + +export interface TuiGroup { + key: TuiGroupKey; + label: string; + rows: TuiRow[]; +} + +/** How the client currently sees the server. */ +export type TuiConnectionStatus = 'connected' | 'reconnecting' | 'degraded' | 'down'; + +/** Which overlay (if any) owns the keyboard. */ +export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'message'; + +/** + * Glyph capability tier. Detection is env-driven and therefore lives in a tiny + * function the app layer calls (`detectGlyphTier`); the renderer only ever + * takes the resolved tier as an input. + */ +export type TuiGlyphTier = 'nerd' | 'unicode' | 'ascii'; + +/** Header facts, all optional: the header degrades to just the product name. */ +export interface TuiHeaderInfo { + hostname?: string; + instance?: string; + version?: string; + /** Plan-usage chip text, e.g. `5h 32% · wk 61%`. */ + planUsage?: string; +} + +/** The selected session's terminal tail, already run through `toDisplayLines()`. */ +export interface TuiPreview { + sessionId: string; + /** Display lines, oldest first. */ + lines: string[]; + /** Set instead of lines when the tail could not be fetched. */ + error?: string; +} + +export interface TuiMessage { + text: string; + tone: 'info' | 'warn' | 'err'; +} + +/** Typed-confirmation state for `x` (kill): the user retypes the session name. */ +export interface TuiConfirmState { + sessionId: string; + name: string; + typed: string; +} + +/** + * What `renderFrame()` reads. The store implements it; a test can hand-build + * one, which is what keeps the renderer testable without the model. + */ +export interface TuiRenderModel { + groups(): TuiGroup[]; + readonly selectedId: string | null; + readonly connection: TuiConnectionStatus; + readonly mode: TuiUiMode; + readonly header: TuiHeaderInfo; + readonly preview: TuiPreview | null; + readonly message: TuiMessage | null; + readonly confirm: TuiConfirmState | null; + /** Live sessions only (RECENT rows are history, not sessions you have open). */ + readonly sessionCount: number; +} diff --git a/test/tui/tui-model.test.ts b/test/tui/tui-model.test.ts new file mode 100644 index 00000000..9ac08952 --- /dev/null +++ b/test/tui/tui-model.test.ts @@ -0,0 +1,283 @@ +/** + * @fileoverview Unit tests for TUI classification, grouping and the cursor. + * + * Rows are built in the shape `GET /api/sessions/unified` really returns + * (`UnifiedSessionItem`, `sources` and all), and approvals in the shape the + * approvals inbox really emits, so a change to either surface breaks these + * tests rather than the dashboard. + */ +import { describe, it, expect } from 'vitest'; +import type { ApprovalItem } from '../../src/web/approval-inbox.js'; +import { + buildRows, + classifySession, + createTuiModel, + flattenRows, + groupSessions, + mergeSessionRow, +} from '../../src/tui/tui-model.js'; +import type { TuiSessionRow } from '../../src/tui/tui-types.js'; + +const NOW = 1_700_000_000_000; + +function session(overrides: Partial & { sessionId: string }): TuiSessionRow { + return { + sources: ['live'], + name: overrides.sessionId, + mode: 'claude', + status: 'idle', + workingDir: '/home/dev/case', + createdAt: NOW - 60_000, + lastActivityAt: NOW - 60_000, + ...overrides, + }; +} + +function approval(overrides: Partial & { sessionId: string }): ApprovalItem { + return { + id: `${overrides.sessionId}:1`, + sessionName: overrides.sessionId, + kind: 'permission', + createdAt: NOW - 30_000, + ...overrides, + }; +} + +function approvalMap(items: ApprovalItem[]): Map { + return new Map(items.map((item) => [item.sessionId, item])); +} + +describe('classifySession', () => { + it('classifies live sessions by status', () => { + expect(classifySession(session({ sessionId: 'a', status: 'busy' }))).toBe('working'); + expect(classifySession(session({ sessionId: 'a', status: 'idle', isWorking: true }))).toBe('working'); + expect(classifySession(session({ sessionId: 'a', status: 'idle' }))).toBe('idle'); + expect(classifySession(session({ sessionId: 'a', status: 'stopped' }))).toBe('idle'); + }); + + it('puts an errored session in the needs-you tier', () => { + expect(classifySession(session({ sessionId: 'a', status: 'error' }))).toBe('waiting'); + }); + + it('classifies by the pending prompt, which outranks a stale busy status', () => { + const row = session({ sessionId: 'a', status: 'busy' }); + expect(classifySession(row, approval({ sessionId: 'a', kind: 'permission' }))).toBe('blocked-permission'); + expect(classifySession(row, approval({ sessionId: 'a', kind: 'question' }))).toBe('blocked-question'); + expect(classifySession(row, approval({ sessionId: 'a', kind: 'idle' }))).toBe('waiting'); + }); + + it('classifies a row the server no longer has live as history', () => { + expect(classifySession(session({ sessionId: 'a', sources: ['history'], status: 'busy' }))).toBe('recent'); + expect(classifySession(session({ sessionId: 'a', sources: ['persisted', 'lifecycle'] }))).toBe('recent'); + expect(classifySession(session({ sessionId: 'a', sources: ['history', 'live'] }))).toBe('idle'); + }); +}); + +describe('groupSessions', () => { + it('always returns the four groups in display order', () => { + expect(groupSessions([]).map((group) => group.key)).toEqual(['needs-you', 'working', 'idle', 'recent']); + expect(groupSessions([]).map((group) => group.label)).toEqual(['NEEDS YOU', 'WORKING', 'IDLE', 'RECENT']); + }); + + it('orders NEEDS YOU by how long each has been blocked, longest first', () => { + const sessions = [session({ sessionId: 'fresh' }), session({ sessionId: 'old' }), session({ sessionId: 'middle' })]; + const approvals = approvalMap([ + approval({ sessionId: 'fresh', createdAt: NOW - 5_000 }), + approval({ sessionId: 'old', createdAt: NOW - 900_000, kind: 'idle' }), + approval({ sessionId: 'middle', createdAt: NOW - 60_000, kind: 'question' }), + ]); + const groups = groupSessions(buildRows(sessions, approvals)); + expect(groups[0].rows.map((row) => row.session.sessionId)).toEqual(['old', 'middle', 'fresh']); + }); + + it('orders WORKING by turn start, longest-running first', () => { + const sessions = [ + session({ sessionId: 'short', status: 'busy', lastSubmitAt: NOW - 10_000, lastActivityAt: NOW }), + session({ sessionId: 'long', status: 'busy', lastSubmitAt: NOW - 3_600_000, lastActivityAt: NOW }), + session({ sessionId: 'nosubmit', status: 'busy', createdAt: NOW - 500, lastActivityAt: NOW }), + ]; + const groups = groupSessions(buildRows(sessions)); + expect(groups[1].rows.map((row) => row.session.sessionId)).toEqual(['long', 'short', 'nosubmit']); + }); + + it('orders IDLE and RECENT newest first', () => { + const sessions = [ + session({ sessionId: 'i-old', lastActivityAt: NOW - 900_000 }), + session({ sessionId: 'i-new', lastActivityAt: NOW - 1_000 }), + session({ sessionId: 'h-old', sources: ['history'], lastActivityAt: NOW - 86_400_000 }), + session({ sessionId: 'h-new', sources: ['history'], lastActivityAt: NOW - 3_600_000 }), + ]; + const groups = groupSessions(buildRows(sessions)); + expect(groups[2].rows.map((row) => row.session.sessionId)).toEqual(['i-new', 'i-old']); + expect(groups[3].rows.map((row) => row.session.sessionId)).toEqual(['h-new', 'h-old']); + }); + + it('caps RECENT', () => { + const sessions = Array.from({ length: 20 }, (_, i) => + session({ sessionId: `h${i}`, sources: ['history'], lastActivityAt: NOW - i * 1000 }) + ); + expect(groupSessions(buildRows(sessions))[3].rows).toHaveLength(8); + expect(groupSessions(buildRows(sessions), { recentLimit: 3 })[3].rows.map((r) => r.session.sessionId)).toEqual([ + 'h0', + 'h1', + 'h2', + ]); + expect(groupSessions(buildRows(sessions), { recentLimit: 0 })[3].rows).toEqual([]); + }); + + it('sorts deterministically when the anchors tie', () => { + const sessions = [ + session({ sessionId: 'b', lastActivityAt: NOW }), + session({ sessionId: 'a', lastActivityAt: NOW }), + ]; + expect(groupSessions(buildRows(sessions))[2].rows.map((row) => row.session.sessionId)).toEqual(['a', 'b']); + }); + + it('sorts an unknown anchor last in both directions', () => { + const withAnchor = session({ sessionId: 'known', lastActivityAt: NOW - 1000 }); + const without = session({ sessionId: 'unknown', lastActivityAt: undefined, createdAt: undefined }); + const idle = groupSessions(buildRows([without, withAnchor]))[2]; + expect(idle.rows.map((row) => row.session.sessionId)).toEqual(['known', 'unknown']); + }); +}); + +describe('mergeSessionRow', () => { + it('keeps fields the incoming row does not carry', () => { + const existing = session({ sessionId: 'a', firstPrompt: 'hello', inputTokens: 10 }); + const merged = mergeSessionRow(existing, { sessionId: 'a', sources: ['live'], status: 'busy' }); + expect(merged.firstPrompt).toBe('hello'); + expect(merged.inputTokens).toBe(10); + expect(merged.status).toBe('busy'); + }); + + it('lets a session lose its live source when the server drops it', () => { + const existing = session({ sessionId: 'a', sources: ['live', 'persisted'] }); + const merged = mergeSessionRow(existing, { sessionId: 'a', sources: ['history'] }); + expect(merged.sources).toEqual(['history']); + expect(classifySession(merged)).toBe('recent'); + }); +}); + +describe('the store', () => { + it('selects the first row as soon as there is one', () => { + const model = createTuiModel(); + expect(model.selectedSession()).toBeNull(); + model.replaceSessions([session({ sessionId: 'a' }), session({ sessionId: 'b' })]); + expect(model.selectedId).toBe(model.rows()[0].session.sessionId); + }); + + it('moves the cursor over rows only, wrapping at both ends', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'needs' }), + session({ sessionId: 'work', status: 'busy', lastSubmitAt: NOW - 1000 }), + session({ sessionId: 'idle' }), + session({ sessionId: 'past', sources: ['history'] }), + ]); + model.setApprovals([approval({ sessionId: 'needs' })]); + // One row per group: the cursor must cross the group headers without stopping. + expect(model.rows().map((row) => row.session.sessionId)).toEqual(['needs', 'work', 'idle', 'past']); + + model.select('needs'); + model.moveCursor(1); + expect(model.selectedId).toBe('work'); + model.moveCursor(-1); + expect(model.selectedId).toBe('needs'); + model.moveCursor(-1); + expect(model.selectedId).toBe('past'); + model.moveCursor(1); + expect(model.selectedId).toBe('needs'); + }); + + it('jumps by 1-based index and refuses one that is off the list', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'a', lastActivityAt: NOW }), + session({ sessionId: 'b', lastActivityAt: NOW - 1 }), + session({ sessionId: 'c', lastActivityAt: NOW - 2 }), + ]); + expect(model.cursorToIndex(3)).toBe(true); + expect(model.selectedId).toBe('c'); + expect(model.cursorToIndex(9)).toBe(false); + expect(model.selectedId).toBe('c'); + expect(model.cursorToIndex(0)).toBe(false); + }); + + it('keeps the selection on its session when the rows re-sort under it', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'a', lastActivityAt: NOW }), + session({ sessionId: 'b', lastActivityAt: NOW - 1000 }), + ]); + model.select('b'); + expect(model.rows()[1].session.sessionId).toBe('b'); + + // b becomes blocked and jumps to the top of the list. + model.setApprovals([approval({ sessionId: 'b' })]); + expect(model.rows()[0].session.sessionId).toBe('b'); + expect(model.selectedId).toBe('b'); + expect(model.selectedSession()?.state).toBe('blocked-permission'); + }); + + it('hands the cursor to whatever takes the place of a removed session', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'a', lastActivityAt: NOW }), + session({ sessionId: 'b', lastActivityAt: NOW - 1 }), + session({ sessionId: 'c', lastActivityAt: NOW - 2 }), + ]); + model.select('b'); + model.removeSession('b'); + expect(model.selectedId).toBe('c'); + model.replaceSessions([session({ sessionId: 'a', lastActivityAt: NOW })]); + expect(model.selectedId).toBe('a'); + model.replaceSessions([]); + expect(model.selectedId).toBeNull(); + expect(model.selectedSession()).toBeNull(); + }); + + it('merges an SSE update into the row it already has', () => { + const model = createTuiModel(); + model.replaceSessions([session({ sessionId: 'a', firstPrompt: 'first thing' })]); + model.upsertSession({ sessionId: 'a', sources: ['live'], status: 'busy', inputTokens: 5 }); + const row = model.rows()[0]; + expect(row.state).toBe('working'); + expect(row.session.firstPrompt).toBe('first thing'); + expect(row.session.inputTokens).toBe(5); + }); + + it('counts only live sessions', () => { + const model = createTuiModel(); + model.replaceSessions([ + session({ sessionId: 'a' }), + session({ sessionId: 'b' }), + session({ sessionId: 'h', sources: ['history'] }), + ]); + expect(model.sessionCount).toBe(2); + expect(flattenRows(model.groups())).toHaveLength(3); + }); + + it('tracks the confirm-kill overlay and only accepts the exact name', () => { + const model = createTuiModel(); + model.replaceSessions([session({ sessionId: 'a', name: 'w4-api' })]); + model.beginConfirmKill(model.rows()[0]); + expect(model.mode).toBe('confirm-kill'); + expect(model.confirmSatisfied()).toBe(false); + model.setConfirmInput('w4-ap'); + expect(model.confirmSatisfied()).toBe(false); + model.setConfirmInput('w4-api'); + expect(model.confirmSatisfied()).toBe(true); + model.closeOverlay(); + expect(model.mode).toBe('list'); + expect(model.confirm).toBeNull(); + }); + + it('drops a session approval along with the session', () => { + const model = createTuiModel(); + model.replaceSessions([session({ sessionId: 'a' })]); + model.setApprovals([approval({ sessionId: 'a' })]); + expect(model.approvalFor('a')).toBeDefined(); + model.removeSession('a'); + expect(model.approvalFor('a')).toBeUndefined(); + }); +});