mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +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
|
||||
*/
|
||||
|
||||
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<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
|
||||
* 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<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). */
|
||||
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();
|
||||
}
|
||||
|
||||
+51
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user