From 3677193027ab3790e3aca64d285607827eead75b Mon Sep 17 00:00:00 2001 From: Luca Martial Date: Mon, 11 May 2026 15:52:25 -0700 Subject: [PATCH] feat(cli): add demo guided tour module with rendering, keypress, and replay Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/setup-demo-tour.test.ts | 151 ++++++++++++ packages/cli/src/setup-demo-tour.ts | 302 +++++++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 packages/cli/src/setup-demo-tour.test.ts create mode 100644 packages/cli/src/setup-demo-tour.ts diff --git a/packages/cli/src/setup-demo-tour.test.ts b/packages/cli/src/setup-demo-tour.test.ts new file mode 100644 index 00000000..a57aace8 --- /dev/null +++ b/packages/cli/src/setup-demo-tour.test.ts @@ -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'); + } + }); +}); diff --git a/packages/cli/src/setup-demo-tour.ts b/packages/cli/src/setup-demo-tour.ts new file mode 100644 index 00000000..d17d138d --- /dev/null +++ b/packages/cli/src/setup-demo-tour.ts @@ -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((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); +}