mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -18,18 +18,23 @@
|
|||||||
* @module tui/tui-model
|
* @module tui/tui-model
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { SearchResultGroup, SearchSourceType } from '../types/search.js';
|
||||||
import type { ApprovalItem } from '../web/approval-inbox.js';
|
import type { ApprovalItem } from '../web/approval-inbox.js';
|
||||||
import type {
|
import type {
|
||||||
TuiConfirmState,
|
TuiConfirmState,
|
||||||
TuiConnectionStatus,
|
TuiConnectionStatus,
|
||||||
|
TuiDigestState,
|
||||||
TuiGroup,
|
TuiGroup,
|
||||||
TuiGroupKey,
|
TuiGroupKey,
|
||||||
TuiHeaderInfo,
|
TuiHeaderInfo,
|
||||||
TuiMessage,
|
TuiMessage,
|
||||||
TuiPickerState,
|
TuiPickerState,
|
||||||
TuiPreview,
|
TuiPreview,
|
||||||
|
TuiPromptState,
|
||||||
TuiRenderModel,
|
TuiRenderModel,
|
||||||
TuiRow,
|
TuiRow,
|
||||||
|
TuiSearchEntry,
|
||||||
|
TuiSearchState,
|
||||||
TuiSessionRow,
|
TuiSessionRow,
|
||||||
TuiSessionState,
|
TuiSessionState,
|
||||||
TuiUiMode,
|
TuiUiMode,
|
||||||
@@ -193,6 +198,71 @@ export function mergeSessionRow(existing: TuiSessionRow, incoming: TuiSessionRow
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Search results (pure)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SEARCH_GROUP_LABELS: Record<SearchSourceType, string> = {
|
||||||
|
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
|
* The dashboard's state. Update methods mutate in place (one store per TUI
|
||||||
* process, no subscribers) and every derived view is recomputed from scratch,
|
* process, no subscribers) and every derived view is recomputed from scratch,
|
||||||
@@ -211,6 +281,9 @@ export class TuiModelStore implements TuiRenderModel {
|
|||||||
message: TuiMessage | null = null;
|
message: TuiMessage | null = null;
|
||||||
confirm: TuiConfirmState | null = null;
|
confirm: TuiConfirmState | null = null;
|
||||||
picker: TuiPickerState | null = null;
|
picker: TuiPickerState | null = null;
|
||||||
|
prompt: TuiPromptState | null = null;
|
||||||
|
search: TuiSearchState | null = null;
|
||||||
|
digest: TuiDigestState | null = null;
|
||||||
recentLimit: number;
|
recentLimit: number;
|
||||||
|
|
||||||
constructor(options: GroupOptions = {}) {
|
constructor(options: GroupOptions = {}) {
|
||||||
@@ -309,6 +382,52 @@ export class TuiModelStore implements TuiRenderModel {
|
|||||||
this.touch();
|
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<TuiSearchState>): 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). */
|
/** Arm the typed-name confirmation for `x` (kill). */
|
||||||
beginConfirmKill(row: TuiRow): void {
|
beginConfirmKill(row: TuiRow): void {
|
||||||
this.confirm = {
|
this.confirm = {
|
||||||
@@ -336,6 +455,9 @@ export class TuiModelStore implements TuiRenderModel {
|
|||||||
this.confirm = null;
|
this.confirm = null;
|
||||||
this.message = null;
|
this.message = null;
|
||||||
this.picker = null;
|
this.picker = null;
|
||||||
|
this.prompt = null;
|
||||||
|
this.search = null;
|
||||||
|
this.digest = null;
|
||||||
this.mode = 'list';
|
this.mode = 'list';
|
||||||
this.touch();
|
this.touch();
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-1
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import type { UnifiedSessionItem } from '../services/unified-session-service.js';
|
import type { UnifiedSessionItem } from '../services/unified-session-service.js';
|
||||||
import type { ApprovalItem } from '../web/approval-inbox.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.
|
* 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';
|
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' | '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
|
* Glyph capability tier. Detection is env-driven and therefore lives in a tiny
|
||||||
@@ -102,6 +103,12 @@ export interface TuiPreview {
|
|||||||
lines: string[];
|
lines: string[];
|
||||||
/** Set instead of lines when the tail could not be fetched. */
|
/** Set instead of lines when the tail could not be fetched. */
|
||||||
error?: string;
|
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 {
|
export interface TuiMessage {
|
||||||
@@ -140,6 +147,46 @@ export interface TuiPickerState {
|
|||||||
hint?: string;
|
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
|
* 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.
|
||||||
@@ -155,6 +202,9 @@ export interface TuiRenderModel {
|
|||||||
readonly confirm: TuiConfirmState | null;
|
readonly confirm: TuiConfirmState | null;
|
||||||
/** Optional so a test can hand-build a model without one. */
|
/** Optional so a test can hand-build a model without one. */
|
||||||
readonly picker?: TuiPickerState | null;
|
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). */
|
/** Live sessions only (RECENT rows are history, not sessions you have open). */
|
||||||
readonly sessionCount: number;
|
readonly sessionCount: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,18 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
|
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 {
|
import {
|
||||||
buildRows,
|
buildRows,
|
||||||
|
buildSearchEntries,
|
||||||
classifySession,
|
classifySession,
|
||||||
createTuiModel,
|
createTuiModel,
|
||||||
|
firstSearchIndex,
|
||||||
flattenRows,
|
flattenRows,
|
||||||
groupSessions,
|
groupSessions,
|
||||||
mergeSessionRow,
|
mergeSessionRow,
|
||||||
|
moveSearchIndex,
|
||||||
} from '../../src/tui/tui-model.js';
|
} from '../../src/tui/tui-model.js';
|
||||||
import type { TuiSessionRow } from '../../src/tui/tui-types.js';
|
import type { TuiSessionRow } from '../../src/tui/tui-types.js';
|
||||||
|
|
||||||
@@ -281,3 +286,105 @@ describe('the store', () => {
|
|||||||
expect(model.approvalFor('a')).toBeUndefined();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user