mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
* refactor(workspace): relocate @ktx/llm source into packages/cli/src/llm * refactor(workspace): rewrite @ktx/llm imports to relative paths * refactor(workspace): fold internal packages into cli * chore(workspace): gate dead-code with knip production mode Turn on production-mode knip plus an autofix run in pre-commit and the `pnpm dead-code` script, document the `/** @internal */` convention for test-only exports in AGENTS.md, annotate test-only exports across the CLI with that JSDoc, and drop dead exports/wrappers the new gate surfaced (e.g. `cli-project.ts`, `lookerRuntimeSourceToFileAdapterSource`, `createLocalScanEnrichmentProvidersFromConfig`, `PGLITE_OWNER_PROCESS_BACKEND_CAPABILITIES`, stale type re-exports). Replace the loose `ignoreIssues` allowlist in `knip.json` with explicit production entries so cross-package barrel leaks are caught. * refactor(cli): delete internal barrel index.ts files The 34 `index.ts` re-export barrels inside `packages/cli/src/` were holdovers from the pre-fold multi-workspace structure. Post-fold-in they served no production purpose: external consumers go through the single package main entry, and in-repo callers mostly imported through them only because the path was short. Internally, knip flagged most barrel re-exports as production-dead (only reached via tests). This change: - Deletes every internal barrel except `packages/cli/src/index.ts` (the published package entry). - Rewrites ~270 source/test files to import each name directly from the file that defines it. - Moves `tools/warehouse-verification/index.ts` to `create-warehouse-verification-tools.ts` (the function it defined locally) and updates its single consumer. - Renames `search/backend-conformance.ts` → `.test-utils.ts` to match the existing test-helper file convention. - Deletes 13 dead test-only chains (dbt-descriptions/*, live-database/extracted-schema, live-database/structural-sync, relationship-* feedback/review chain) plus their tests and a cascading orphan integration test. - Updates test mocks that pointed at deleted barrel paths (notion-client, connector barrels in scan/local-scan-connectors tests) to mock the source files instead. - Points the maintainer benchmark script (`scripts/relationship-benchmark-report.mjs`) at source files instead of `dist/context/scan/index.js`. - Drops the barrel `!` entries from `knip.json`; adds explicit production entries only for the benchmark code reached via dist by the maintainer script. Net: 413 files changed, ~1.2k insertions, ~9.4k deletions. `pnpm run dead-code` (Biome + knip default + knip production) and `pnpm run type-check` are clean; 2277 tests pass. * refactor(workspace): rename @ktx/cli to @kaelio/ktx and pack it directly Promote the CLI workspace package to the public name `@kaelio/ktx` and drop the separate `scripts/build-public-npm-package.mjs` wrapper. The CLI package is now publishable in place (`publishConfig.access: public`, `provenance: true`), so artifact packing uses `pnpm pack` against `packages/cli/` instead of assembling a parallel package tree. Updates all workspace filter invocations, docs, tests, and release readiness checks to reference the new package name, and folds the tarball-name helper into `scripts/public-npm-release-metadata.mjs`. * docs: align "agent clients" and "data agents" terminology Replace "client agents" with "agent clients" and "database agents" with "data agents" across AGENTS.md, README.md, the docs-site copy, and the matching setup-agents test description, matching the canonical vocabulary in docs/terminology.md. Also moves packages/cli/tsconfig.json's tsBuildInfoFile from node_modules/.cache/ to dist/.tsbuildinfo so incremental builds survive node_modules reinstalls. * refactor(release): single source of truth for package version Make packages/cli/package.json the single source of truth for the @kaelio/ktx version. publicNpmPackageVersion() now reads it directly, so artifact filenames, release-readiness checks, and the Python wheel version all derive from one field. The duplicate release-policy.json.publicNpmPackageVersion is removed. Previously the two fields could drift: tarballs were named kaelio-ktx-0.4.1.tgz while internally containing @kaelio/ktx@0.0.0-private. - update-public-release-version.mjs rewrites both Python pyproject.toml files (ktx-daemon, ktx-sl) alongside the npm package.jsons, normalizing the version for PEP 440 (e.g. 0.1.0-rc.2 -> 0.1.0rc2). - semantic-release-config.cjs adds the two pyproject.toml files to @semantic-release/git assets so the release commit back to main carries every version source in lockstep. - The six "?? '0.0.0-private'" fallback literals across the CLI are replaced with "?? getKtxCliPackageInfo().version", and createDefaultKtxMcpServer makes its version arg required. - docs/release.md describes the actual commit-back model: the dev tree always reflects the most recent release; no sentinel pin to maintain. Verified: pnpm run artifacts:build now produces kaelio-ktx-0.4.1.tgz and kaelio_ktx-0.4.1-py3-none-any.whl with @kaelio/ktx@0.4.1 inside. Full type-check, dead-code, and 2287 vitests + 173 script tests pass. * refactor(cli): inject embedding provider resolution and detect sentence-transformers runtime Make resolveProjectEmbeddingProvider and runtimeIo injectable in ingest and scan command entrypoints so tests can stub them, and teach resolvePublicIngestRuntimeRequirements to flag the local-embeddings runtime feature when ktx.yaml selects sentence-transformers. * chore(cli): mark buildLocalStatsStatus and LocalStatsStatus as @internal Both symbols are consumed only by status-project.test.ts. Annotating with /** @internal */ keeps knip's production-mode check clean without changing runtime behavior. * fix(cli): use real package metadata in print-command-tree The stubbed package name embedded a forbidden product identifier that tripped the boundary check in CI. Read the metadata from package.json instead — keeps the rendered tree unchanged and removes a duplicate source of truth. * feat(cli): show embedding coverage in `ktx status`, drop duplicate disk counts Inline `(N embedded)` next to the Wiki scope counts and Semantic-layer source counts, computed with `SUM(embedding_json IS NOT NULL)` over `knowledge_pages` and `local_sl_sources`. Rename the "Knowledge" label to "Wiki" (canonical per `docs/terminology.md`) and rename the matching `localStats.knowledgePages` field to `localStats.wikiPages`. Drop `wiki=N md` and `semantic-layer=N yaml` from the Disk row — those duplicated the per-surface rows above. Disk now reports only actual byte usage (db, cache, raw-sources). The unused `wikiGlobalMarkdownCount` / `semanticLayerYamlCount` fields, the `isMarkdownEntry` / `isYamlEntry` helpers, and the `filter` arg on `summarizeDir` are removed.
540 lines
17 KiB
TypeScript
540 lines
17 KiB
TypeScript
/* @jsxImportSource react */
|
||
import { buildMemoryFlowViewModel } from './context/ingest/memory-flow/view-model.js';
|
||
import {
|
||
createInitialMemoryFlowInteractionState,
|
||
findMemoryFlowSearchMatches,
|
||
reduceMemoryFlowInteractionState,
|
||
selectedMemoryFlowColumn,
|
||
selectedMemoryFlowDetails,
|
||
} from './context/ingest/memory-flow/interaction.js';
|
||
import type {
|
||
MemoryFlowColumnId,
|
||
MemoryFlowInteractionCommand,
|
||
MemoryFlowInteractionState,
|
||
MemoryFlowReplayInput,
|
||
MemoryFlowViewModel,
|
||
} from './context/ingest/memory-flow/types.js';
|
||
import { Box, Text, render as renderInkRuntime, useApp, useInput } from 'ink';
|
||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||
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;
|
||
}
|
||
|
||
/** @internal */
|
||
export interface MemoryFlowInkInstance {
|
||
rerender(tree: ReactNode): void;
|
||
unmount(): void;
|
||
waitUntilExit(): Promise<void>;
|
||
clear?(): void;
|
||
}
|
||
|
||
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]');
|
||
}
|
||
|
||
/** @internal */
|
||
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 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>
|
||
);
|
||
}
|
||
|
||
/** @internal */
|
||
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 [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);
|
||
}, props.frameMs ?? DEFAULT_TUI_TIMING.frameMs);
|
||
return () => clearInterval(timer);
|
||
}, [props.frameMs]);
|
||
|
||
useEffect(() => {
|
||
if (lastEventCountRef.current !== pacedInput.events.length) {
|
||
lastEventCountRef.current = pacedInput.events.length;
|
||
}
|
||
}, [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 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;
|
||
}
|
||
}
|