mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
feat(cli): add demo guided tour module with rendering, keypress, and replay
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e52713ca1e
commit
3677193027
2 changed files with 453 additions and 0 deletions
151
packages/cli/src/setup-demo-tour.test.ts
Normal file
151
packages/cli/src/setup-demo-tour.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildDemoReplayTimeline,
|
||||
DEMO_REPLAY_TARGETS,
|
||||
renderDemoAgentTransition,
|
||||
renderDemoBanner,
|
||||
renderDemoCardContent,
|
||||
renderDemoCompletionSummary,
|
||||
} from './setup-demo-tour.js';
|
||||
|
||||
/** Strip ANSI escape sequences for plain-text assertions. */
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('renderDemoBanner', () => {
|
||||
it('contains "Demo mode"', () => {
|
||||
const plain = stripAnsi(renderDemoBanner());
|
||||
expect(plain).toContain('Demo mode');
|
||||
});
|
||||
|
||||
it('mentions pre-processed data', () => {
|
||||
const plain = stripAnsi(renderDemoBanner());
|
||||
expect(plain).toContain('pre-processed');
|
||||
});
|
||||
|
||||
it('mentions read-only', () => {
|
||||
const plain = stripAnsi(renderDemoBanner());
|
||||
expect(plain).toContain('read-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDemoCardContent', () => {
|
||||
it('contains the title', () => {
|
||||
const plain = stripAnsi(renderDemoCardContent('Database connection', ['Postgres']));
|
||||
expect(plain).toContain('Database connection');
|
||||
});
|
||||
|
||||
it('contains each selection', () => {
|
||||
const plain = stripAnsi(renderDemoCardContent('Sources', ['dbt', 'metabase']));
|
||||
expect(plain).toContain('dbt');
|
||||
expect(plain).toContain('metabase');
|
||||
});
|
||||
|
||||
it('contains navigation hints', () => {
|
||||
const plain = stripAnsi(renderDemoCardContent('Title', ['a']));
|
||||
expect(plain).toContain('Press Enter to continue');
|
||||
expect(plain).toContain('Escape to go back');
|
||||
});
|
||||
|
||||
it('works with multiple selections', () => {
|
||||
const result = renderDemoCardContent('Pick', ['one', 'two', 'three']);
|
||||
const plain = stripAnsi(result);
|
||||
expect(plain).toContain('one');
|
||||
expect(plain).toContain('two');
|
||||
expect(plain).toContain('three');
|
||||
// Each selection gets a ▸ bullet
|
||||
const bullets = (plain.match(/▸/g) ?? []).length;
|
||||
expect(bullets).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDemoAgentTransition', () => {
|
||||
it('contains "Demo project is ready"', () => {
|
||||
const plain = stripAnsi(renderDemoAgentTransition());
|
||||
expect(plain).toContain('Demo project is ready');
|
||||
});
|
||||
|
||||
it('mentions connecting an agent', () => {
|
||||
const plain = stripAnsi(renderDemoAgentTransition());
|
||||
expect(plain).toContain('connect your agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDemoCompletionSummary', () => {
|
||||
const projectDir = '/tmp/ktx-demo-123';
|
||||
|
||||
it('includes the project path', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
|
||||
expect(plain).toContain(projectDir);
|
||||
});
|
||||
|
||||
it('includes a temp directory warning', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
|
||||
expect(plain).toContain('temporary demo directory');
|
||||
});
|
||||
|
||||
it('points to ktx setup for real data', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
|
||||
expect(plain).toContain('ktx setup');
|
||||
});
|
||||
|
||||
it('shows agent-connected message when installed', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
|
||||
expect(plain).toContain('agent is connected');
|
||||
});
|
||||
|
||||
it('shows manual instructions when agent not installed', () => {
|
||||
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, false));
|
||||
expect(plain).toContain('agent not installed');
|
||||
expect(plain).toContain('--agents');
|
||||
expect(plain).toContain(`--project-dir ${projectDir}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDemoReplayTimeline', () => {
|
||||
const timeline = buildDemoReplayTimeline();
|
||||
const connectionIds = new Set(timeline.map((e) => e.connectionId));
|
||||
|
||||
it('produces events for all 4 targets', () => {
|
||||
expect(connectionIds.size).toBe(4);
|
||||
expect(connectionIds).toContain('demo-warehouse');
|
||||
expect(connectionIds).toContain('dbt');
|
||||
expect(connectionIds).toContain('metabase');
|
||||
expect(connectionIds).toContain('notion');
|
||||
});
|
||||
|
||||
it('all targets end as done', () => {
|
||||
for (const id of connectionIds) {
|
||||
const events = timeline.filter((e) => e.connectionId === id);
|
||||
const last = events[events.length - 1];
|
||||
expect(last.status).toBe('done');
|
||||
}
|
||||
});
|
||||
|
||||
it('events are sorted by delayMs', () => {
|
||||
for (let i = 1; i < timeline.length; i++) {
|
||||
expect(timeline[i].delayMs).toBeGreaterThanOrEqual(timeline[i - 1].delayMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEMO_REPLAY_TARGETS', () => {
|
||||
it('has 1 primary source', () => {
|
||||
expect(DEMO_REPLAY_TARGETS.primarySources).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('has 3 context sources', () => {
|
||||
expect(DEMO_REPLAY_TARGETS.contextSources).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('primary source is a scan operation', () => {
|
||||
expect(DEMO_REPLAY_TARGETS.primarySources[0].operation).toBe('scan');
|
||||
});
|
||||
|
||||
it('context sources are source-ingest operations', () => {
|
||||
for (const source of DEMO_REPLAY_TARGETS.contextSources) {
|
||||
expect(source.operation).toBe('source-ingest');
|
||||
}
|
||||
});
|
||||
});
|
||||
302
packages/cli/src/setup-demo-tour.ts
Normal file
302
packages/cli/src/setup-demo-tour.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import type {
|
||||
ContextBuildTargetState,
|
||||
ContextBuildViewState,
|
||||
} from './context-build-view.js';
|
||||
import { createRepainter, renderContextBuildView } from './context-build-view.js';
|
||||
import type { KtxPublicIngestPlanTarget } from './public-ingest.js';
|
||||
import { KtxSetupExitError } from './setup-interrupt.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANSI helpers (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ESC = String.fromCharCode(0x1b);
|
||||
|
||||
function cyan(text: string): string {
|
||||
return `${ESC}[36m${text}${ESC}[39m`;
|
||||
}
|
||||
|
||||
function dim(text: string): string {
|
||||
return `${ESC}[2m${text}${ESC}[22m`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo target helpers (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createDemoTarget(
|
||||
connectionId: string,
|
||||
operation: 'scan' | 'source-ingest',
|
||||
driver: string,
|
||||
): KtxPublicIngestPlanTarget {
|
||||
const adapter = operation === 'source-ingest' ? driver : undefined;
|
||||
return {
|
||||
connectionId,
|
||||
driver,
|
||||
operation,
|
||||
...(adapter ? { adapter } : {}),
|
||||
debugCommand: `ktx setup context build --target ${connectionId}`,
|
||||
steps: operation === 'scan'
|
||||
? ['scan', 'enrich', 'memory-update']
|
||||
: ['source-ingest', 'enrich', 'memory-update'],
|
||||
};
|
||||
}
|
||||
|
||||
function createTargetState(target: KtxPublicIngestPlanTarget): ContextBuildTargetState {
|
||||
return {
|
||||
target,
|
||||
status: 'queued',
|
||||
detailLine: null,
|
||||
summaryText: null,
|
||||
startedAt: null,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure rendering functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderDemoBanner(): string {
|
||||
const lines = [
|
||||
'',
|
||||
`┌ ${cyan('Demo mode')} — data has been pre-processed and KTX context is already built.`,
|
||||
'│ This walkthrough illustrates the setup steps. Selections are pre-filled and read-only.',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderDemoCardContent(title: string, selections: string[]): string {
|
||||
const lines = [
|
||||
`┌ ${title}`,
|
||||
'│',
|
||||
...selections.map((s) => `│ ${cyan('▸')} ${s}`),
|
||||
'│',
|
||||
`│ ${dim('Press Enter to continue, Escape to go back')}`,
|
||||
'└',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderDemoAgentTransition(): string {
|
||||
const lines = [
|
||||
'┌ Demo project is ready — let\'s connect your agent',
|
||||
'│',
|
||||
'│ Your KTX context has been built with demo data.',
|
||||
'│ Select an agent to start using it.',
|
||||
'└',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderDemoCompletionSummary(projectDir: string, agentInstalled: boolean): string {
|
||||
const lines: string[] = [''];
|
||||
|
||||
if (agentInstalled) {
|
||||
lines.push('┌ Your agent is connected to a demo KTX project.');
|
||||
} else {
|
||||
lines.push('┌ Demo project created (agent not installed).');
|
||||
lines.push('│');
|
||||
lines.push(`│ To connect an agent manually, run:`);
|
||||
lines.push(`│ ${cyan(`ktx setup --agents --project-dir ${projectDir}`)}`);
|
||||
}
|
||||
|
||||
lines.push('│');
|
||||
lines.push(`│ ${dim('This is a temporary demo directory — data will not persist across sessions.')}`);
|
||||
lines.push(`│ Run ${cyan('ktx setup')} to connect your own data sources.`);
|
||||
lines.push('│');
|
||||
lines.push(`│ Project: ${projectDir}`);
|
||||
lines.push('└');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keypress navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function waitForDemoNavigation(
|
||||
stdin?: NodeJS.ReadStream,
|
||||
): Promise<'forward' | 'back'> {
|
||||
const input = stdin ?? process.stdin;
|
||||
const hadRawMode = input.isRaw ?? false;
|
||||
|
||||
return new Promise<'forward' | 'back'>((resolve, reject) => {
|
||||
if (typeof input.setRawMode === 'function') {
|
||||
input.setRawMode(true);
|
||||
}
|
||||
input.resume();
|
||||
|
||||
const cleanup = () => {
|
||||
input.off('data', onData);
|
||||
if (typeof input.setRawMode === 'function') {
|
||||
input.setRawMode(hadRawMode);
|
||||
}
|
||||
};
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const char = data.toString();
|
||||
if (char === '\r' || char === '\n') {
|
||||
cleanup();
|
||||
resolve('forward');
|
||||
} else if (char === '\x1b') {
|
||||
cleanup();
|
||||
resolve('back');
|
||||
} else if (char === '\x03') {
|
||||
cleanup();
|
||||
reject(new KtxSetupExitError());
|
||||
}
|
||||
};
|
||||
|
||||
input.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function renderDemoCard(
|
||||
title: string,
|
||||
selections: string[],
|
||||
io: KtxCliIo,
|
||||
stdin?: NodeJS.ReadStream,
|
||||
waitNav: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'> = waitForDemoNavigation,
|
||||
): Promise<'forward' | 'back'> {
|
||||
io.stdout.write(renderDemoBanner() + '\n\n');
|
||||
io.stdout.write(renderDemoCardContent(title, selections) + '\n');
|
||||
return waitNav(stdin);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context build replay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DemoReplayEvent {
|
||||
delayMs: number;
|
||||
connectionId: string;
|
||||
status: 'running' | 'done';
|
||||
detailLine: string | null;
|
||||
summaryText: string | null;
|
||||
}
|
||||
|
||||
export const DEMO_REPLAY_TARGETS = {
|
||||
primarySources: [
|
||||
createDemoTarget('demo-warehouse', 'scan', 'postgres'),
|
||||
],
|
||||
contextSources: [
|
||||
createDemoTarget('dbt', 'source-ingest', 'dbt'),
|
||||
createDemoTarget('metabase', 'source-ingest', 'metabase'),
|
||||
createDemoTarget('notion', 'source-ingest', 'notion'),
|
||||
],
|
||||
} as const;
|
||||
|
||||
export function buildDemoReplayTimeline(): DemoReplayEvent[] {
|
||||
return [
|
||||
// demo-warehouse: scan
|
||||
{ delayMs: 0, connectionId: 'demo-warehouse', status: 'running', detailLine: null, summaryText: null },
|
||||
{ delayMs: 600, connectionId: 'demo-warehouse', status: 'running', detailLine: '[50%] Scanning tables...', summaryText: null },
|
||||
{ delayMs: 1200, connectionId: 'demo-warehouse', status: 'done', detailLine: null, summaryText: '12 tables' },
|
||||
// dbt
|
||||
{ delayMs: 1200, connectionId: 'dbt', status: 'running', detailLine: null, summaryText: null },
|
||||
{ delayMs: 1800, connectionId: 'dbt', status: 'running', detailLine: '[60%] Ingesting models...', summaryText: null },
|
||||
{ delayMs: 2200, connectionId: 'dbt', status: 'done', detailLine: null, summaryText: '8 models' },
|
||||
// metabase
|
||||
{ delayMs: 2200, connectionId: 'metabase', status: 'running', detailLine: null, summaryText: null },
|
||||
{ delayMs: 2800, connectionId: 'metabase', status: 'done', detailLine: null, summaryText: '5 dashboards' },
|
||||
// notion
|
||||
{ delayMs: 2800, connectionId: 'notion', status: 'running', detailLine: null, summaryText: null },
|
||||
{ delayMs: 3400, connectionId: 'notion', status: 'done', detailLine: null, summaryText: '3 pages' },
|
||||
];
|
||||
}
|
||||
|
||||
function renderDemoContextCompletionSummary(): string {
|
||||
const lines = [
|
||||
'',
|
||||
'┌ Context build complete',
|
||||
'│',
|
||||
'│ All sources have been processed.',
|
||||
'│',
|
||||
`│ ${dim('Press Enter to continue, Escape to go back')}`,
|
||||
'└',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function runDemoContextReplay(
|
||||
io: KtxCliIo,
|
||||
stdin?: NodeJS.ReadStream,
|
||||
): Promise<'forward' | 'back'> {
|
||||
const allPrimary = DEMO_REPLAY_TARGETS.primarySources.map(createTargetState);
|
||||
const allContext = DEMO_REPLAY_TARGETS.contextSources.map(createTargetState);
|
||||
|
||||
const state: ContextBuildViewState = {
|
||||
primarySources: allPrimary,
|
||||
contextSources: allContext,
|
||||
frame: 0,
|
||||
startedAt: Date.now(),
|
||||
totalElapsedMs: 0,
|
||||
};
|
||||
|
||||
const allTargets = [...allPrimary, ...allContext];
|
||||
const timeline = buildDemoReplayTimeline();
|
||||
|
||||
const repainter = createRepainter(io);
|
||||
const paint = () => repainter.paint(renderContextBuildView(state, { styled: true }));
|
||||
|
||||
paint();
|
||||
|
||||
let eventIndex = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const frameInterval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
state.frame++;
|
||||
state.totalElapsedMs = elapsed;
|
||||
|
||||
// Apply all events up to the current elapsed time
|
||||
while (eventIndex < timeline.length && timeline[eventIndex].delayMs <= elapsed) {
|
||||
const event = timeline[eventIndex];
|
||||
const target = allTargets.find((t) => t.target.connectionId === event.connectionId);
|
||||
if (target) {
|
||||
target.status = event.status;
|
||||
target.detailLine = event.detailLine;
|
||||
if (event.summaryText !== null) {
|
||||
target.summaryText = event.summaryText;
|
||||
}
|
||||
if (event.status === 'running' && target.startedAt === null) {
|
||||
target.startedAt = Date.now();
|
||||
}
|
||||
if (event.status === 'done') {
|
||||
target.elapsedMs = target.startedAt !== null ? Date.now() - target.startedAt : 0;
|
||||
}
|
||||
}
|
||||
eventIndex++;
|
||||
}
|
||||
|
||||
// Update running target elapsed times
|
||||
for (const t of allTargets) {
|
||||
if (t.status === 'running' && t.startedAt !== null) {
|
||||
t.elapsedMs = Date.now() - t.startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
paint();
|
||||
|
||||
// Check if all events have been applied
|
||||
if (eventIndex >= timeline.length) {
|
||||
clearInterval(frameInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
|
||||
// Final paint with all done
|
||||
paint();
|
||||
|
||||
// Show completion summary and wait for navigation
|
||||
io.stdout.write(renderDemoContextCompletionSummary() + '\n');
|
||||
return waitForDemoNavigation(stdin);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue