From dc89f05b14d8e492147281071fb3d24bc5100d41 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 20:29:20 +0200 Subject: [PATCH] feat: hold composer, search and digest state in the TUI model The store gains the three overlays phase 2 needs, each taking the keyboard when it is set and all of them cleared together by closeOverlay(), plus the pure flattening of `GET /api/search`'s typed groups into rows a cursor can move over: headers are chrome, and only a session that is on the list counts as selectable, since a history hit has no row to move the cursor to. Co-Authored-By: Claude Fable 5 --- src/tui/tui-model.ts | 122 +++++++++++++++++++++++++++++++++++++ src/tui/tui-types.ts | 52 +++++++++++++++- test/tui/tui-model.test.ts | 107 ++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 1 deletion(-) diff --git a/src/tui/tui-model.ts b/src/tui/tui-model.ts index 2512f131..aebaa15d 100644 --- a/src/tui/tui-model.ts +++ b/src/tui/tui-model.ts @@ -18,18 +18,23 @@ * @module tui/tui-model */ +import type { SearchResultGroup, SearchSourceType } from '../types/search.js'; import type { ApprovalItem } from '../web/approval-inbox.js'; import type { TuiConfirmState, TuiConnectionStatus, + TuiDigestState, TuiGroup, TuiGroupKey, TuiHeaderInfo, TuiMessage, TuiPickerState, TuiPreview, + TuiPromptState, TuiRenderModel, TuiRow, + TuiSearchEntry, + TuiSearchState, TuiSessionRow, TuiSessionState, TuiUiMode, @@ -193,6 +198,71 @@ export function mergeSessionRow(existing: TuiSessionRow, incoming: TuiSessionRow return merged; } +// ───────────────────────────────────────────────────────────────────────────── +// Search results (pure) +// ───────────────────────────────────────────────────────────────────────────── + +const SEARCH_GROUP_LABELS: Record = { + session: 'SESSIONS', + event: 'EVENTS', + file: 'FILES', +}; + +/** + * Flatten `GET /api/search`'s typed groups into the overlay's lines: a header + * per group, then its results. Only a result row carries a session id, which is + * what the cursor uses to skip headers. + * + * `isLive` decides which rows can hand the dashboard a session: a history hit + * has a session id too, but selecting it would move the cursor to a row that is + * not on the list. + */ +export function buildSearchEntries( + groups: readonly SearchResultGroup[], + isLive: (sessionId: string) => boolean +): TuiSearchEntry[] { + const entries: TuiSearchEntry[] = []; + for (const group of groups) { + if (group.results.length === 0) continue; + entries.push({ kind: 'header', text: SEARCH_GROUP_LABELS[group.type] ?? group.type.toUpperCase() }); + for (const result of group.results) { + const live = result.jumpTo.kind === 'session' && isLive(result.sessionId); + entries.push({ + kind: 'result', + text: result.jumpTo.relativePath ?? result.sessionName ?? result.sessionId.slice(0, 8), + detail: result.snippet, + sessionId: result.sessionId, + live, + }); + } + } + return entries; +} + +/** First selectable row, or -1 when the list is all headers (or empty). */ +export function firstSearchIndex(entries: readonly TuiSearchEntry[]): number { + return entries.findIndex((entry) => entry.kind === 'result'); +} + +/** + * Move the search cursor by `delta` result rows, skipping headers and stopping + * at both ends (wrapping a search result list scrolls past the answer the user + * was reading). + */ +export function moveSearchIndex(entries: readonly TuiSearchEntry[], index: number, delta: number): number { + const step = Math.trunc(delta); + if (step === 0) return index; + const direction = step > 0 ? 1 : -1; + let current = index; + for (let remaining = Math.abs(step); remaining > 0; remaining--) { + let next = current + direction; + while (next >= 0 && next < entries.length && entries[next].kind !== 'result') next += direction; + if (next < 0 || next >= entries.length) break; + current = next; + } + return current; +} + /** * The dashboard's state. Update methods mutate in place (one store per TUI * process, no subscribers) and every derived view is recomputed from scratch, @@ -211,6 +281,9 @@ export class TuiModelStore implements TuiRenderModel { message: TuiMessage | null = null; confirm: TuiConfirmState | null = null; picker: TuiPickerState | null = null; + prompt: TuiPromptState | null = null; + search: TuiSearchState | null = null; + digest: TuiDigestState | null = null; recentLimit: number; constructor(options: GroupOptions = {}) { @@ -309,6 +382,52 @@ export class TuiModelStore implements TuiRenderModel { this.touch(); } + /** Open (or close) the one-line prompt composer. Setting one takes the keyboard. */ + setPrompt(prompt: TuiPromptState | null): void { + this.prompt = prompt; + this.mode = prompt ? 'prompt' : 'list'; + this.touch(); + } + + /** Replace the composer's editor state, keeping the target session. */ + updatePrompt(composer: TuiPromptState['composer']): void { + if (!this.prompt || this.prompt.composer === composer) return; + this.prompt = { ...this.prompt, composer }; + this.touch(); + } + + setSearch(search: TuiSearchState | null): void { + this.search = search; + this.mode = search ? 'search' : 'list'; + this.touch(); + } + + /** Fold a partial update into the open search overlay. No-op when it is closed. */ + updateSearch(patch: Partial): void { + if (!this.search) return; + this.search = { ...this.search, ...patch }; + this.touch(); + } + + setDigest(digest: TuiDigestState | null): void { + this.digest = digest; + this.mode = digest ? 'digest' : 'list'; + this.touch(); + } + + /** + * Scroll the digest by `delta` lines. `capacity` is how many lines the box + * shows, so the last page cannot scroll into empty space. + */ + scrollDigest(delta: number, capacity: number): void { + if (!this.digest) return; + const room = Math.max(0, this.digest.lines.length - Math.max(1, Math.trunc(capacity))); + const offset = Math.min(Math.max(0, this.digest.offset + Math.trunc(delta)), room); + if (offset === this.digest.offset) return; + this.digest = { ...this.digest, offset }; + this.touch(); + } + /** Arm the typed-name confirmation for `x` (kill). */ beginConfirmKill(row: TuiRow): void { this.confirm = { @@ -336,6 +455,9 @@ export class TuiModelStore implements TuiRenderModel { this.confirm = null; this.message = null; this.picker = null; + this.prompt = null; + this.search = null; + this.digest = null; this.mode = 'list'; this.touch(); } diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index ffbf9e39..c30232be 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -15,6 +15,7 @@ import type { UnifiedSessionItem } from '../services/unified-session-service.js'; import type { ApprovalItem } from '../web/approval-inbox.js'; +import type { TuiComposerState } from './tui-composer.js'; /** * A unified-list row plus the few live-only extras the dashboard shows. @@ -77,7 +78,7 @@ export interface TuiGroup { export type TuiConnectionStatus = 'connected' | 'reconnecting' | 'degraded' | 'down'; /** Which overlay (if any) owns the keyboard. */ -export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'message' | 'new-session'; +export type TuiUiMode = 'list' | 'help' | 'confirm-kill' | 'prompt' | 'search' | 'digest' | 'message' | 'new-session'; /** * Glyph capability tier. Detection is env-driven and therefore lives in a tiny @@ -102,6 +103,12 @@ export interface TuiPreview { lines: string[]; /** Set instead of lines when the tail could not be fetched. */ error?: string; + /** + * Set instead of lines when there is nothing to fetch (a history row has no + * live buffer). Distinct from `error`: nothing failed, so it must not read + * like something did. + */ + note?: string; } export interface TuiMessage { @@ -140,6 +147,46 @@ export interface TuiPickerState { hint?: string; } +/** The `p` composer: one line aimed at one session. */ +export interface TuiPromptState { + sessionId: string; + /** What the session is called on screen, for the footer prefix. */ + label: string; + composer: TuiComposerState; +} + +/** + * One line of the `/` overlay. Group headers are chrome (the API returns typed + * groups), so only `result` rows are selectable. + */ +export interface TuiSearchEntry { + kind: 'header' | 'result'; + text: string; + detail?: string; + sessionId?: string; + /** The row can hand the dashboard a session that is open right now. */ + live?: boolean; +} + +export interface TuiSearchState { + composer: TuiComposerState; + /** The query `entries` answer. Lags the composer while a search is in flight. */ + query: string; + entries: TuiSearchEntry[]; + /** Index into `entries`, always a `result` row; -1 when none is selectable. */ + index: number; + status: 'idle' | 'searching' | 'done' | 'error'; + /** One line under the query: what happened, or why there is nothing. */ + note?: string; +} + +/** The `g` overlay: pre-formatted lines plus where the window starts. */ +export interface TuiDigestState { + title: string; + lines: string[]; + offset: number; +} + /** * What `renderFrame()` reads. The store implements it; a test can hand-build * one, which is what keeps the renderer testable without the model. @@ -155,6 +202,9 @@ export interface TuiRenderModel { readonly confirm: TuiConfirmState | null; /** Optional so a test can hand-build a model without one. */ readonly picker?: TuiPickerState | null; + readonly prompt?: TuiPromptState | null; + readonly search?: TuiSearchState | null; + readonly digest?: TuiDigestState | 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 index 9ac08952..1a9c1924 100644 --- a/test/tui/tui-model.test.ts +++ b/test/tui/tui-model.test.ts @@ -8,13 +8,18 @@ */ import { describe, it, expect } from 'vitest'; import type { ApprovalItem } from '../../src/web/approval-inbox.js'; +import type { SearchResultGroup } from '../../src/types/search.js'; +import { createComposer } from '../../src/tui/tui-composer.js'; import { buildRows, + buildSearchEntries, classifySession, createTuiModel, + firstSearchIndex, flattenRows, groupSessions, mergeSessionRow, + moveSearchIndex, } from '../../src/tui/tui-model.js'; import type { TuiSessionRow } from '../../src/tui/tui-types.js'; @@ -281,3 +286,105 @@ describe('the store', () => { expect(model.approvalFor('a')).toBeUndefined(); }); }); + +describe('the phase-2 overlays', () => { + it('gives one overlay the keyboard at a time and clears them together', () => { + const model = createTuiModel(); + model.setPrompt({ sessionId: 'a', label: 'w4-api', composer: createComposer('hi') }); + expect(model.mode).toBe('prompt'); + model.setSearch({ composer: createComposer(), query: '', entries: [], index: -1, status: 'idle' }); + expect(model.mode).toBe('search'); + model.setDigest({ title: 'Away digest', lines: ['a', 'b'], offset: 0 }); + expect(model.mode).toBe('digest'); + model.closeOverlay(); + expect(model.mode).toBe('list'); + expect([model.prompt, model.search, model.digest]).toEqual([null, null, null]); + }); + + it('keeps the composer pointed at its session while the text changes', () => { + const model = createTuiModel(); + model.setPrompt({ sessionId: 'a', label: 'w4-api', composer: createComposer() }); + const revision = model.revision; + model.updatePrompt(createComposer('deploy')); + expect(model.prompt?.sessionId).toBe('a'); + expect(model.revision).toBeGreaterThan(revision); + }); + + it('scrolls the digest without running off either end', () => { + const model = createTuiModel(); + model.setDigest({ title: 'Away digest', lines: Array.from({ length: 10 }, (_, i) => `line ${i}`), offset: 0 }); + model.scrollDigest(3, 4); + expect(model.digest?.offset).toBe(3); + model.scrollDigest(100, 4); + expect(model.digest?.offset).toBe(6); + model.scrollDigest(-100, 4); + expect(model.digest?.offset).toBe(0); + }); +}); + +describe('search results', () => { + const groups: SearchResultGroup[] = [ + { + type: 'session', + results: [ + { + type: 'session', + sessionId: 'live-1', + sessionName: 'w1-alpha', + timestamp: NOW, + snippet: '/tmp/alpha', + exactMatch: true, + jumpTo: { kind: 'session', sessionId: 'live-1' }, + }, + { + type: 'session', + sessionId: 'past-1', + sessionName: 'w9-old', + timestamp: NOW - 1000, + snippet: '/tmp/old', + exactMatch: false, + jumpTo: { kind: 'resume-session', sessionId: 'past-1' }, + }, + ], + }, + { + type: 'file', + results: [ + { + type: 'file', + sessionId: 'live-1', + sessionName: 'w1-alpha', + timestamp: NOW, + snippet: 'notes.md', + exactMatch: false, + jumpTo: { kind: 'file-preview', sessionId: 'live-1', relativePath: 'docs/notes.md' }, + }, + ], + }, + ]; + + it('flattens the typed groups into headers and rows', () => { + const entries = buildSearchEntries(groups, (id) => id === 'live-1'); + expect(entries.map((entry) => entry.kind)).toEqual(['header', 'result', 'result', 'header', 'result']); + expect(entries[0].text).toBe('SESSIONS'); + expect(entries[1]).toMatchObject({ text: 'w1-alpha', sessionId: 'live-1', live: true }); + // A session that is not on the list cannot be selected into. + expect(entries[2]).toMatchObject({ text: 'w9-old', live: false }); + expect(entries[4]).toMatchObject({ text: 'docs/notes.md', live: false }); + }); + + it('drops an empty group instead of printing a header with nothing under it', () => { + expect(buildSearchEntries([{ type: 'event', results: [] }], () => false)).toEqual([]); + }); + + it('starts on the first result and never lands on a header', () => { + const entries = buildSearchEntries(groups, () => true); + expect(firstSearchIndex(entries)).toBe(1); + expect(moveSearchIndex(entries, 1, 1)).toBe(2); + expect(moveSearchIndex(entries, 2, 1)).toBe(4); + // Both ends stop rather than wrap: a result list is read, not cycled. + expect(moveSearchIndex(entries, 4, 1)).toBe(4); + expect(moveSearchIndex(entries, 1, -1)).toBe(1); + expect(firstSearchIndex([])).toBe(-1); + }); +});