ktx/packages/cli/src/memory-flow-tui.tsx
2026-05-10 23:51:24 +02:00

552 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* @jsxImportSource react */
import {
buildMemoryFlowViewModel,
buildMemoryFlowVisualModel,
createInitialMemoryFlowInteractionState,
findMemoryFlowSearchMatches,
type MemoryFlowColumnId,
type MemoryFlowInteractionCommand,
type MemoryFlowInteractionState,
type MemoryFlowReplayInput,
type MemoryFlowViewModel,
reduceMemoryFlowInteractionState,
selectedMemoryFlowColumn,
selectedMemoryFlowDetails,
} from '@ktx/context/ingest';
import { Box, Text, render as renderInkRuntime, useApp, useInput } from 'ink';
import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { buildDemoMetrics } from './demo-metrics.js';
import {
ActivityFeed,
Hud,
Logo,
} from './memory-flow-hud.js';
import { profileMark } from './startup-profile.js';
profileMark('module:memory-flow-tui');
const COLOR_THEME = {
text: 'white',
muted: 'gray',
active: 'cyan',
complete: 'green',
warning: 'yellow',
failed: 'red',
border: 'gray',
} as const;
const NO_COLOR_THEME = {
text: 'white',
muted: 'white',
active: 'white',
complete: 'white',
warning: 'white',
failed: 'white',
border: 'white',
} as const;
type MemoryFlowTuiTheme = Record<keyof typeof COLOR_THEME, string>;
const STAGE_LABELS = {
source: 'CONNECT',
chunks: 'SNAPSHOT',
workUnits: 'PLAN',
actions: 'ANALYZE',
gates: 'VALIDATE',
saved: 'MEMORY',
} satisfies Record<MemoryFlowColumnId, string>;
export interface KtxMemoryFlowTuiIo {
stdin?: { isTTY?: boolean; setRawMode?(value: boolean): void };
stdout: { isTTY?: boolean; columns?: number; write(chunk: string): void };
stderr: { write(chunk: string): void };
}
export interface MemoryFlowTuiLiveSession {
update(input: MemoryFlowReplayInput): void;
close(): void;
isClosed(): boolean;
}
export interface MemoryFlowInkInstance {
rerender(tree: ReactNode): void;
unmount(): void;
waitUntilExit(): Promise<void>;
clear?(): void;
}
export interface MemoryFlowInkRenderOptions {
stdin?: KtxMemoryFlowTuiIo['stdin'];
stdout: KtxMemoryFlowTuiIo['stdout'];
stderr: KtxMemoryFlowTuiIo['stderr'];
exitOnCtrlC: boolean;
patchConsole: boolean;
maxFps: number;
alternateScreen: boolean;
}
interface RenderMemoryFlowTuiOptions {
renderInk?: (tree: ReactNode, options: MemoryFlowInkRenderOptions) => MemoryFlowInkInstance;
paceEvents?: boolean;
paceMsPerEvent?: number;
speedMultiplier?: number;
}
interface StartLiveMemoryFlowTuiOptions {
renderInk?: (tree: ReactNode, options: MemoryFlowInkRenderOptions) => MemoryFlowInkInstance;
}
interface RenderTreeOptions {
paceEvents?: boolean;
paceMsPerEvent?: number;
frameMs?: number;
completionFrameMs?: number;
completionHoldMs?: number;
}
interface MemoryFlowTuiTiming {
paceMsPerEvent: number;
frameMs: number;
completionFrameMs: number;
completionHoldMs: number;
}
const DEFAULT_TUI_TIMING = {
paceMsPerEvent: 180,
frameMs: 140,
completionFrameMs: 80,
completionHoldMs: 1000,
} satisfies MemoryFlowTuiTiming;
interface InkKey {
leftArrow?: boolean;
rightArrow?: boolean;
upArrow?: boolean;
downArrow?: boolean;
return?: boolean;
escape?: boolean;
ctrl?: boolean;
shift?: boolean;
tab?: boolean;
backspace?: boolean;
delete?: boolean;
}
interface MemoryFlowTuiAppProps {
input: MemoryFlowReplayInput;
terminalWidth?: number;
env?: NodeJS.ProcessEnv;
onExit(): void;
paceEvents?: boolean;
paceMsPerEvent?: number;
frameMs?: number;
completionFrameMs?: number;
completionHoldMs?: number;
showBoot?: boolean;
}
function resolveMemoryFlowTuiTheme(env: NodeJS.ProcessEnv = process.env): MemoryFlowTuiTheme {
if (env.NO_COLOR || env.TERM === 'dumb') {
return NO_COLOR_THEME;
}
return COLOR_THEME;
}
export function sanitizeMemoryFlowTuiError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message
.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted-url]')
.replace(/\b(api[_-]?key|password|token|secret)=\S+/gi, '[redacted]');
}
export function memoryFlowCommandForInkInput(
input: string,
key: InkKey,
search: MemoryFlowInteractionState['search'] = { editing: false, query: '', matchIndex: 0 },
): MemoryFlowInteractionCommand | null {
if (search.editing) {
if (key.escape) return 'search-clear';
if (key.return) return 'search-submit';
if (key.backspace || key.delete) return 'search-backspace';
if (key.downArrow || (key.tab && !key.shift)) return 'search-next';
if (key.upArrow || (key.tab && key.shift)) return 'search-previous';
if (input.length === 1 && input >= ' ' && input !== '') {
return { type: 'search-input', value: input };
}
return null;
}
if (key.ctrl === true && input === 'c') return 'quit';
if (input === '/') return 'search-start';
if (search.query && input === 'n') return 'search-next';
if (search.query && input === 'N') return 'search-previous';
if (input === '') return 'left';
if (input === '') return 'right';
if (input === '') return 'up';
if (input === '') return 'down';
if (key.leftArrow) return 'left';
if (key.rightArrow) return 'right';
if (key.upArrow) return 'up';
if (key.downArrow) return 'down';
if (key.return) return 'enter';
if (key.tab) return 'tab';
if (input === 'f') return 'filter';
if (input === 'p') return 'provenance';
if (input === 't') return 'transcript';
if (input === 'q' || key.escape) return 'quit';
return null;
}
function stageLabel(columnId: MemoryFlowColumnId): string {
return STAGE_LABELS[columnId];
}
function statusLabel(status: string): 'OK' | 'RUN' | 'WARN' | 'FAIL' | 'WAIT' {
if (status === 'complete') return 'OK';
if (status === 'active') return 'RUN';
if (status === 'warning') return 'WARN';
if (status === 'failed') return 'FAIL';
return 'WAIT';
}
function filterLabel(filter: MemoryFlowInteractionState['filter']): string {
return filter === 'failed_or_flagged' ? 'issues' : 'all';
}
function searchStatusLine(view: MemoryFlowViewModel, state: MemoryFlowInteractionState): string | null {
if (!state.search.editing && state.search.query.length === 0) {
return null;
}
const matches = findMemoryFlowSearchMatches(view, state.search.query);
const status = state.search.editing ? 'editing' : 'locked';
const position = matches.length === 0 ? '0/0' : `${state.search.matchIndex + 1}/${matches.length}`;
return `Search: ${state.search.query || '/'} (${position} matches, ${status})`;
}
function humanizeDemoText(value: string): string {
return value
.replace(/\bWORKUNITS\b/g, 'PLAN')
.replace(/\bWorkUnit\b/g, 'Table review')
.replace(/\bwork units\b/gi, 'table reviews')
.replace(/\bwork-unit\b/gi, 'table-review')
.replace(/\bWUs\b/g, 'tables')
.replace(/\bchunks\b/gi, 'table groups')
.replace(/\bcandidates\b/gi, 'drafts')
.replace(/\bcandidate\b/gi, 'draft')
.replace(/\braw files\b/gi, 'database files')
.replace(/\braw file\b/gi, 'database file')
.replace(/\bSL\b/g, 'context layer');
}
function DetailsPane(props: {
view: MemoryFlowViewModel;
state: MemoryFlowInteractionState;
theme: MemoryFlowTuiTheme;
}): ReactNode {
const column = selectedMemoryFlowColumn(props.view, props.state);
const details = selectedMemoryFlowDetails(props.view, props.state).map(humanizeDemoText).slice(0, 8);
const rawFiles = Array.from(
new Set([
...props.view.details.actions.flatMap((action) => action.rawFiles),
...props.view.details.provenance.map((row) => row.rawPath),
]),
).slice(0, 4);
const searchLine = searchStatusLine(props.view, props.state);
return (
<Box flexDirection="column">
<Text color={props.theme.active}>
Details / focus: {stageLabel(column.id)} Pane: {props.state.pane} Filter: {filterLabel(props.state.filter)}
</Text>
{searchLine && <Text color={props.theme.active}>{searchLine}</Text>}
{details.map((detail, index) => (
<Text key={`${index}-${detail}`} color={props.theme.text}>
- {detail}
</Text>
))}
{rawFiles.map((rawFile) => (
<Text key={rawFile} color={props.theme.muted}>
- {rawFile}
</Text>
))}
{props.view.completionLine && <Text color={props.theme.complete}>{humanizeDemoText(props.view.completionLine)}</Text>}
</Box>
);
}
function TrustIssues(props: { view: MemoryFlowViewModel; theme: MemoryFlowTuiTheme }): ReactNode {
if (props.view.trustIssues.length === 0) {
return null;
}
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={props.theme.warning}>Validation notes</Text>
{props.view.trustIssues.slice(0, 4).map((issue) => (
<Text
key={`${issue.severity}-${issue.title}`}
color={issue.severity === 'failed' ? props.theme.failed : props.theme.warning}
>
{issue.severity === 'failed' ? 'FAILED' : 'WARNING'} {humanizeDemoText(issue.title)}:{' '}
{humanizeDemoText(issue.detail)}
</Text>
))}
</Box>
);
}
export function MemoryFlowTuiApp(props: MemoryFlowTuiAppProps): ReactNode {
const app = useApp();
const totalEvents = props.input.events.length;
const paceEnabled = props.paceEvents === true && totalEvents > 0;
const [pacedCount, setPacedCount] = useState<number>(paceEnabled ? 0 : totalEvents);
const pacedInput = useMemo<MemoryFlowReplayInput>(() => {
if (!paceEnabled || pacedCount >= totalEvents) {
return props.input;
}
return {
...props.input,
status: 'running',
events: props.input.events.slice(0, pacedCount),
};
}, [paceEnabled, pacedCount, totalEvents, props.input]);
const pacedNow = useMemo<(() => number) | undefined>(() => {
if (!paceEnabled) return undefined;
const firstEvent = props.input.events[0];
if (!firstEvent?.emittedAt) return undefined;
const firstEventMs = Date.parse(firstEvent.emittedAt);
if (!Number.isFinite(firstEventMs)) return undefined;
const stride = props.paceMsPerEvent ?? DEFAULT_TUI_TIMING.paceMsPerEvent;
return () => firstEventMs + pacedCount * stride;
}, [paceEnabled, pacedCount, props.input.events, props.paceMsPerEvent]);
const view = useMemo(() => buildMemoryFlowViewModel(pacedInput), [pacedInput]);
const [state, setState] = useState<MemoryFlowInteractionState>(() => createInitialMemoryFlowInteractionState(view));
const [frame, setFrame] = useState(0);
const [thoughtFrame, setThoughtFrame] = useState(0);
const [completionFrame, setCompletionFrame] = useState(0);
const [holdComplete, setHoldComplete] = useState(false);
const [userHasNavigated, setUserHasNavigated] = useState(false);
const lastEventCountRef = useRef(pacedInput.events.length);
const lastStatusRef = useRef(pacedInput.status);
const exitHandled = useRef(false);
const theme = resolveMemoryFlowTuiTheme(props.env);
useEffect(() => {
if (!state.shouldQuit || exitHandled.current) {
return;
}
exitHandled.current = true;
props.onExit();
app.exit();
}, [app, props, state.shouldQuit]);
useEffect(() => {
const timer = setInterval(() => {
setFrame((current) => current + 1);
setThoughtFrame((current) => current + 1);
}, props.frameMs ?? DEFAULT_TUI_TIMING.frameMs);
return () => clearInterval(timer);
}, [props.frameMs]);
useEffect(() => {
if (lastEventCountRef.current !== pacedInput.events.length) {
lastEventCountRef.current = pacedInput.events.length;
setThoughtFrame(0);
}
}, [pacedInput.events.length]);
useEffect(() => {
if (lastStatusRef.current !== pacedInput.status) {
lastStatusRef.current = pacedInput.status;
if (pacedInput.status === 'done' || pacedInput.status === 'error') {
setCompletionFrame(0);
}
}
}, [pacedInput.status]);
useEffect(() => {
if (pacedInput.status !== 'done' && pacedInput.status !== 'error') return;
if (completionFrame >= 12) return;
const timer = setInterval(
() => setCompletionFrame((current) => Math.min(12, current + 1)),
props.completionFrameMs ?? DEFAULT_TUI_TIMING.completionFrameMs,
);
return () => clearInterval(timer);
}, [pacedInput.status, completionFrame, props.completionFrameMs]);
useEffect(() => {
if (completionFrame < 12) {
setHoldComplete(false);
return;
}
const timer = setTimeout(
() => setHoldComplete(true),
props.completionHoldMs ?? DEFAULT_TUI_TIMING.completionHoldMs,
);
return () => clearTimeout(timer);
}, [completionFrame, props.completionHoldMs]);
useEffect(() => {
if (!paceEnabled || pacedCount >= totalEvents) {
return;
}
const interval = props.paceMsPerEvent ?? DEFAULT_TUI_TIMING.paceMsPerEvent;
const timer = setInterval(() => {
setPacedCount((current) => Math.min(totalEvents, current + 1));
}, interval);
return () => clearInterval(timer);
}, [paceEnabled, pacedCount, totalEvents, props.paceMsPerEvent]);
useInput((input, key) => {
const command = memoryFlowCommandForInkInput(input, key, state.search);
if (!command) return;
if (command === 'quit' && isComplete && !holdComplete) return;
if (command !== 'quit') setUserHasNavigated(true);
setState((current) => reduceMemoryFlowInteractionState(current, command, view));
});
const isComplete = pacedInput.status === 'done' || pacedInput.status === 'error';
const completionMetrics = useMemo(
() => buildDemoMetrics(pacedInput, pacedNow ? { now: pacedNow } : {}),
[pacedInput, pacedNow],
);
const termWidth = props.terminalWidth ?? 80;
return (
<Box flexDirection="column" paddingX={1}>
<Logo theme={theme} done={isComplete} />
<Hud input={pacedInput} theme={theme} frame={frame} width={termWidth} now={pacedNow} />
<ActivityFeed input={pacedInput} theme={theme} frame={frame} width={termWidth} completionFrame={completionFrame} showCompletion={isComplete} holdComplete={holdComplete} />
<TrustIssues view={view} theme={theme} />
{userHasNavigated && <DetailsPane view={view} state={state} theme={theme} />}
</Box>
);
}
function renderTree(
input: MemoryFlowReplayInput,
io: KtxMemoryFlowTuiIo,
onExit: () => void,
options: RenderTreeOptions = {},
): ReactNode {
return (
<MemoryFlowTuiApp
input={input}
terminalWidth={io.stdout.columns ?? process.stdout.columns}
onExit={onExit}
paceEvents={options.paceEvents}
paceMsPerEvent={options.paceMsPerEvent}
frameMs={options.frameMs}
completionFrameMs={options.completionFrameMs}
completionHoldMs={options.completionHoldMs}
/>
);
}
function renderInk(tree: ReactNode, options: MemoryFlowInkRenderOptions): MemoryFlowInkInstance {
return renderInkRuntime(tree, {
stdin: options.stdin as NodeJS.ReadStream | undefined,
stdout: options.stdout as NodeJS.WriteStream,
stderr: options.stderr as NodeJS.WriteStream,
exitOnCtrlC: options.exitOnCtrlC,
patchConsole: options.patchConsole,
maxFps: options.maxFps,
alternateScreen: options.alternateScreen,
}) as MemoryFlowInkInstance;
}
function renderOptions(io: KtxMemoryFlowTuiIo): MemoryFlowInkRenderOptions {
return {
stdin: io.stdin,
stdout: io.stdout,
stderr: io.stderr,
exitOnCtrlC: false,
patchConsole: false,
maxFps: 30,
alternateScreen: true,
};
}
function scaleTiming(ms: number, speedMultiplier: number): number {
return Math.max(20, Math.round(ms / speedMultiplier));
}
function resolveTiming(options: RenderMemoryFlowTuiOptions): MemoryFlowTuiTiming {
const speedMultiplier =
typeof options.speedMultiplier === 'number' && options.speedMultiplier > 0 ? options.speedMultiplier : 1;
return {
paceMsPerEvent:
typeof options.paceMsPerEvent === 'number' && options.paceMsPerEvent > 0
? options.paceMsPerEvent
: scaleTiming(DEFAULT_TUI_TIMING.paceMsPerEvent, speedMultiplier),
frameMs: DEFAULT_TUI_TIMING.frameMs,
completionFrameMs: DEFAULT_TUI_TIMING.completionFrameMs,
completionHoldMs: DEFAULT_TUI_TIMING.completionHoldMs,
};
}
export async function renderMemoryFlowTui(
input: MemoryFlowReplayInput,
io: KtxMemoryFlowTuiIo,
options: RenderMemoryFlowTuiOptions = {},
): Promise<boolean> {
let instance: MemoryFlowInkInstance | null = null;
const paceEvents = options.paceEvents !== false;
const timing = resolveTiming(options);
try {
const onExit = (): void => {
instance?.unmount();
};
instance = (options.renderInk ?? renderInk)(
renderTree(input, io, onExit, { paceEvents, ...timing }),
renderOptions(io),
);
await instance.waitUntilExit();
instance.unmount();
return true;
} catch (error) {
io.stderr.write(`TUI visualization unavailable: ${sanitizeMemoryFlowTuiError(error)}; using text renderer.\n`);
return false;
}
}
export async function startLiveMemoryFlowTui(
input: MemoryFlowReplayInput,
io: KtxMemoryFlowTuiIo,
options: StartLiveMemoryFlowTuiOptions = {},
): Promise<MemoryFlowTuiLiveSession | null> {
let instance: MemoryFlowInkInstance | null = null;
let closed = false;
const close = (): void => {
if (closed) {
return;
}
closed = true;
instance?.unmount();
};
try {
instance = (options.renderInk ?? renderInk)(renderTree(input, io, close), renderOptions(io));
return {
update(nextInput: MemoryFlowReplayInput): void {
if (closed) {
return;
}
instance?.rerender(renderTree(nextInput, io, close));
},
close,
isClosed(): boolean {
return closed;
},
};
} catch (error) {
io.stderr.write(`TUI visualization unavailable: ${sanitizeMemoryFlowTuiError(error)}; using text renderer.\n`);
return null;
}
}