mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<TuiGroupKey, string> = {
|
||||
'needs-you': 'NEEDS YOU',
|
||||
working: 'WORKING',
|
||||
idle: 'IDLE',
|
||||
recent: 'RECENT',
|
||||
};
|
||||
|
||||
const STATE_GROUP: Record<TuiSessionState, TuiGroupKey> = {
|
||||
'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<string, ApprovalItem> = 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<TuiGroupKey, TuiRow[]> = {
|
||||
'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<string, unknown>)[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<string, TuiSessionRow>();
|
||||
private approvalsBySession = new Map<string, ApprovalItem>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TuiSessionRow> & { 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<ApprovalItem> & { sessionId: string }): ApprovalItem {
|
||||
return {
|
||||
id: `${overrides.sessionId}:1`,
|
||||
sessionName: overrides.sessionId,
|
||||
kind: 'permission',
|
||||
createdAt: NOW - 30_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalMap(items: ApprovalItem[]): Map<string, ApprovalItem> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user