mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-28 08:49:38 +02:00
553 lines
18 KiB
TypeScript
553 lines
18 KiB
TypeScript
|
|
/* @jsxImportSource react */
|
|||
|
|
import {
|
|||
|
|
buildMemoryFlowViewModel,
|
|||
|
|
buildMemoryFlowVisualModel,
|
|||
|
|
createInitialMemoryFlowInteractionState,
|
|||
|
|
findMemoryFlowSearchMatches,
|
|||
|
|
type MemoryFlowColumnId,
|
|||
|
|
type MemoryFlowInteractionCommand,
|
|||
|
|
type MemoryFlowInteractionState,
|
|||
|
|
type MemoryFlowReplayInput,
|
|||
|
|
type MemoryFlowViewModel,
|
|||
|
|
reduceMemoryFlowInteractionState,
|
|||
|
|
selectedMemoryFlowColumn,
|
|||
|
|
selectedMemoryFlowDetails,
|
|||
|
|
} from '@klo/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 KloMemoryFlowTuiIo {
|
|||
|
|
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?: KloMemoryFlowTuiIo['stdin'];
|
|||
|
|
stdout: KloMemoryFlowTuiIo['stdout'];
|
|||
|
|
stderr: KloMemoryFlowTuiIo['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 === '[D') return 'left';
|
|||
|
|
if (input === '[C') return 'right';
|
|||
|
|
if (input === '[A') return 'up';
|
|||
|
|
if (input === '[B') 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: KloMemoryFlowTuiIo,
|
|||
|
|
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: KloMemoryFlowTuiIo): 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: KloMemoryFlowTuiIo,
|
|||
|
|
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: KloMemoryFlowTuiIo,
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|