feat: give the TUI model a revision signal and picker state

The app layer repaints on state change, so the store has to be able to
say that something changed: `revision` is bumped by every mutating
method, and the repaint test compares it against the last painted frame.
Without it an idle dashboard would either redraw on a timer or go stale.

Three additions come with it, all optional so nothing existing changes
shape: `TuiSessionRow.muxName` (the unified list carries no mux name, so
the app fills it in from the local tmux enumeration and a row without one
cannot be attached), a `new-session` UI mode, and `TuiPickerState`, the
one-column chooser behind `n`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 21:35:31 +02:00
parent b828920102
commit c25aaca96b
2 changed files with 87 additions and 9 deletions
+51 -8
View File
@@ -26,6 +26,7 @@ import type {
TuiGroupKey, TuiGroupKey,
TuiHeaderInfo, TuiHeaderInfo,
TuiMessage, TuiMessage,
TuiPickerState,
TuiPreview, TuiPreview,
TuiRenderModel, TuiRenderModel,
TuiRow, TuiRow,
@@ -200,6 +201,7 @@ export function mergeSessionRow(existing: TuiSessionRow, incoming: TuiSessionRow
export class TuiModelStore implements TuiRenderModel { export class TuiModelStore implements TuiRenderModel {
private sessionsById = new Map<string, TuiSessionRow>(); private sessionsById = new Map<string, TuiSessionRow>();
private approvalsBySession = new Map<string, ApprovalItem>(); private approvalsBySession = new Map<string, ApprovalItem>();
private _revision = 0;
selectedId: string | null = null; selectedId: string | null = null;
connection: TuiConnectionStatus = 'connected'; connection: TuiConnectionStatus = 'connected';
@@ -208,12 +210,27 @@ export class TuiModelStore implements TuiRenderModel {
preview: TuiPreview | null = null; preview: TuiPreview | null = null;
message: TuiMessage | null = null; message: TuiMessage | null = null;
confirm: TuiConfirmState | null = null; confirm: TuiConfirmState | null = null;
picker: TuiPickerState | null = null;
recentLimit: number; recentLimit: number;
constructor(options: GroupOptions = {}) { constructor(options: GroupOptions = {}) {
this.recentLimit = Math.max(0, Math.floor(options.recentLimit ?? DEFAULT_RECENT_LIMIT)); this.recentLimit = Math.max(0, Math.floor(options.recentLimit ?? DEFAULT_RECENT_LIMIT));
} }
/**
* Bumped by every mutating method. The app layer repaints when this changed
* (plus on resize and on the animation tick), which is what keeps an idle
* dashboard from redrawing itself. Writing a public field directly bypasses
* it, so state changes go through the methods below.
*/
get revision(): number {
return this._revision;
}
private touch(): void {
this._revision++;
}
// ── Data ─────────────────────────────────────────────────────────────────── // ── Data ───────────────────────────────────────────────────────────────────
upsertSession(session: TuiSessionRow): void { upsertSession(session: TuiSessionRow): void {
@@ -258,24 +275,38 @@ export class TuiModelStore implements TuiRenderModel {
// ── Chrome ───────────────────────────────────────────────────────────────── // ── Chrome ─────────────────────────────────────────────────────────────────
setConnection(status: TuiConnectionStatus): void { setConnection(status: TuiConnectionStatus): void {
if (this.connection === status) return;
this.connection = status; this.connection = status;
this.touch();
} }
setHeader(header: TuiHeaderInfo): void { setHeader(header: TuiHeaderInfo): void {
this.header = { ...this.header, ...header }; this.header = { ...this.header, ...header };
this.touch();
} }
setPreview(preview: TuiPreview | null): void { setPreview(preview: TuiPreview | null): void {
this.preview = preview; this.preview = preview;
this.touch();
} }
setMode(mode: TuiUiMode): void { setMode(mode: TuiUiMode): void {
if (this.mode === mode) return;
this.mode = mode; this.mode = mode;
this.touch();
} }
setMessage(message: TuiMessage | null): void { setMessage(message: TuiMessage | null): void {
this.message = message; this.message = message;
this.mode = message ? 'message' : 'list'; this.mode = message ? 'message' : 'list';
this.touch();
}
/** Show (or clear) the overlay chooser. Setting one takes the keyboard. */
setPicker(picker: TuiPickerState | null): void {
this.picker = picker;
this.mode = picker ? 'new-session' : 'list';
this.touch();
} }
/** Arm the typed-name confirmation for `x` (kill). */ /** Arm the typed-name confirmation for `x` (kill). */
@@ -286,10 +317,13 @@ export class TuiModelStore implements TuiRenderModel {
typed: '', typed: '',
}; };
this.mode = 'confirm-kill'; this.mode = 'confirm-kill';
this.touch();
} }
setConfirmInput(typed: string): void { setConfirmInput(typed: string): void {
if (this.confirm) this.confirm = { ...this.confirm, typed }; if (!this.confirm) return;
this.confirm = { ...this.confirm, typed };
this.touch();
} }
/** Does the typed text authorize the kill? Exact match on the name shown. */ /** Does the typed text authorize the kill? Exact match on the name shown. */
@@ -301,7 +335,9 @@ export class TuiModelStore implements TuiRenderModel {
closeOverlay(): void { closeOverlay(): void {
this.confirm = null; this.confirm = null;
this.message = null; this.message = null;
this.picker = null;
this.mode = 'list'; this.mode = 'list';
this.touch();
} }
// ── Derived views ────────────────────────────────────────────────────────── // ── Derived views ──────────────────────────────────────────────────────────
@@ -330,7 +366,7 @@ export class TuiModelStore implements TuiRenderModel {
/** Select a session by id. Returns false when it is not on screen. */ /** Select a session by id. Returns false when it is not on screen. */
select(sessionId: string): boolean { select(sessionId: string): boolean {
if (!this.rows().some((row) => row.session.sessionId === sessionId)) return false; if (!this.rows().some((row) => row.session.sessionId === sessionId)) return false;
this.selectedId = sessionId; this.moveTo(sessionId);
return true; return true;
} }
@@ -338,17 +374,17 @@ export class TuiModelStore implements TuiRenderModel {
moveCursor(delta: number): void { moveCursor(delta: number): void {
const rows = this.rows(); const rows = this.rows();
if (rows.length === 0) { if (rows.length === 0) {
this.selectedId = null; this.moveTo(null);
return; return;
} }
const current = this.indexOfSelected(rows); const current = this.indexOfSelected(rows);
if (current < 0) { if (current < 0) {
this.selectedId = rows[delta >= 0 ? 0 : rows.length - 1].session.sessionId; this.moveTo(rows[delta >= 0 ? 0 : rows.length - 1].session.sessionId);
return; return;
} }
const step = Math.trunc(delta); const step = Math.trunc(delta);
const next = (((current + step) % rows.length) + rows.length) % rows.length; const next = (((current + step) % rows.length) + rows.length) % rows.length;
this.selectedId = rows[next].session.sessionId; this.moveTo(rows[next].session.sessionId);
} }
/** The 1-9 jump: `n` is the 1-based position in the flattened list. */ /** The 1-9 jump: `n` is the 1-based position in the flattened list. */
@@ -356,10 +392,16 @@ export class TuiModelStore implements TuiRenderModel {
const rows = this.rows(); const rows = this.rows();
const index = Math.trunc(n) - 1; const index = Math.trunc(n) - 1;
if (index < 0 || index >= rows.length) return false; if (index < 0 || index >= rows.length) return false;
this.selectedId = rows[index].session.sessionId; this.moveTo(rows[index].session.sessionId);
return true; return true;
} }
private moveTo(sessionId: string | null): void {
if (this.selectedId === sessionId) return;
this.selectedId = sessionId;
this.touch();
}
private indexOfSelected(rows: readonly TuiRow[] = this.rows()): number { private indexOfSelected(rows: readonly TuiRow[] = this.rows()): number {
if (!this.selectedId) return -1; if (!this.selectedId) return -1;
return rows.findIndex((row) => row.session.sessionId === this.selectedId); return rows.findIndex((row) => row.session.sessionId === this.selectedId);
@@ -373,14 +415,15 @@ export class TuiModelStore implements TuiRenderModel {
private mutate(apply: () => void): void { private mutate(apply: () => void): void {
const previousIndex = this.indexOfSelected(); const previousIndex = this.indexOfSelected();
apply(); apply();
this.touch();
const rows = this.rows(); const rows = this.rows();
if (rows.length === 0) { if (rows.length === 0) {
this.selectedId = null; this.moveTo(null);
return; return;
} }
if (this.selectedId !== null && rows.some((row) => row.session.sessionId === this.selectedId)) 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); const index = Math.min(Math.max(previousIndex, 0), rows.length - 1);
this.selectedId = rows[index].session.sessionId; this.moveTo(rows[index].session.sessionId);
} }
} }
+36 -1
View File
@@ -33,6 +33,15 @@ export interface TuiSessionRow extends UnifiedSessionItem {
lastSubmitAt?: number; lastSubmitAt?: number;
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
/**
* tmux session name to attach to (`codeman-<first 8 of the id>`).
*
* The unified list does not carry it (no server view merges the mux name into
* a row), so the app layer fills it in from the local tmux enumeration, which
* is also the only thing that proves the pane really exists. A row without one
* cannot be attached: it is either history or a direct-PTY session.
*/
muxName?: string;
} }
/** /**
@@ -68,7 +77,7 @@ export interface TuiGroup {
export type TuiConnectionStatus = 'connected' | 'reconnecting' | 'degraded' | 'down'; export type TuiConnectionStatus = 'connected' | 'reconnecting' | 'degraded' | 'down';
/** Which overlay (if any) owns the keyboard. */ /** Which overlay (if any) owns the keyboard. */
export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'message'; export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'message' | 'new-session';
/** /**
* Glyph capability tier. Detection is env-driven and therefore lives in a tiny * Glyph capability tier. Detection is env-driven and therefore lives in a tiny
@@ -107,6 +116,30 @@ export interface TuiConfirmState {
typed: string; typed: string;
} }
export interface TuiPickerItem {
/** What choosing this item means to the caller; never shown. */
id: string;
label: string;
/** Second column, dimmed (a case path, a mode description). */
detail?: string;
}
/**
* A one-column chooser drawn as an overlay (the case and mode pickers behind
* `n`). Items are already filtered: the app owns the unfiltered list, the
* renderer only paints what it is given.
*/
export interface TuiPickerState {
title: string;
items: TuiPickerItem[];
/** Index into `items`; -1 when the list is empty. */
index: number;
/** Current filter text, when the picker filters as you type. */
filter?: string;
/** One line above the list: what is being chosen, or why the list is empty. */
hint?: string;
}
/** /**
* What `renderFrame()` reads. The store implements it; a test can hand-build * What `renderFrame()` reads. The store implements it; a test can hand-build
* one, which is what keeps the renderer testable without the model. * one, which is what keeps the renderer testable without the model.
@@ -120,6 +153,8 @@ export interface TuiRenderModel {
readonly preview: TuiPreview | null; readonly preview: TuiPreview | null;
readonly message: TuiMessage | null; readonly message: TuiMessage | null;
readonly confirm: TuiConfirmState | null; readonly confirm: TuiConfirmState | null;
/** Optional so a test can hand-build a model without one. */
readonly picker?: TuiPickerState | null;
/** Live sessions only (RECENT rows are history, not sessions you have open). */ /** Live sessions only (RECENT rows are history, not sessions you have open). */
readonly sessionCount: number; readonly sessionCount: number;
} }