mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
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>
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
/**
|
|
* @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);
|
|
}
|