Merge origin/main into schema select UX branch

This commit is contained in:
Luca Martial 2026-05-11 23:15:03 -07:00
commit b70bf6bd67
148 changed files with 8577 additions and 526 deletions

View file

@ -91,22 +91,17 @@ describe('demo assets', () => {
expect(manifest.sources.bi.explores).toBeGreaterThanOrEqual(2);
expect(manifest.sources.bi.dashboards).toBeGreaterThanOrEqual(2);
expect(manifest.sources.notion.pages).toBeGreaterThanOrEqual(5);
expect(manifest.generated.semanticLayer.sourceCount).toBeGreaterThanOrEqual(5);
expect(manifest.generated.knowledge.pageCount).toBeGreaterThanOrEqual(10);
expect(manifest.generated.semanticLayer.sourceCount).toBeGreaterThanOrEqual(40);
expect(manifest.generated.knowledge.pageCount).toBeGreaterThanOrEqual(20);
expect(manifest.generated.links.linkCount).toBeGreaterThanOrEqual(10);
const dbStat = await stat(packagedDemoAssetPath('demo.db'));
expect(dbStat.size).toBeGreaterThan(0);
expect(dbStat.size).toBeLessThan(10 * 1024 * 1024);
await expect(access(packagedDemoAssetPath('raw-sources/warehouse/accounts.csv'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('raw-sources/dbt/schema.yml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('raw-sources/bi/revenue_exec.dashboard.lookml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('raw-sources/notion/revenue-reporting-policy.md'))).resolves.toBeUndefined();
expect(manifest.generated.semanticLayer.path).toBe('semantic-layer/orbit_demo');
await expect(access(packagedDemoAssetPath('semantic-layer/orbit_demo/accounts.yaml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('knowledge/global/arr-contract-first.md'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('semantic-layer/dbt-main/mart_arr_daily.yaml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('semantic-layer/postgres-warehouse/mart_account_activity.yaml'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('knowledge/global/orbit-company-overview.md'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('links/provenance.json'))).resolves.toBeUndefined();
await expect(access(packagedDemoAssetPath('reports/seeded-demo-report.json'))).resolves.toBeUndefined();
});

View file

@ -45,14 +45,9 @@ const REQUIRED_SEEDED_ASSET_PATHS = [
'demo.db',
'manifest.json',
DEMO_REPLAY_FILE,
join('raw-sources', 'warehouse', 'accounts.csv'),
join('raw-sources', 'dbt', 'schema.yml'),
join('raw-sources', 'bi', 'revenue_exec.dashboard.lookml'),
join('raw-sources', 'notion', 'revenue-reporting-policy.md'),
join('semantic-layer', 'orbit_demo', 'accounts.yaml'),
join('knowledge', 'global', 'arr-contract-first.md'),
join('links', 'provenance.json'),
join('reports', 'seeded-demo-report.json'),
join('semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'),
join('semantic-layer', 'postgres-warehouse', 'mart_account_activity.yaml'),
join('knowledge', 'global', 'orbit-company-overview.md'),
] as const;
function assetDir(): string {

View file

@ -53,8 +53,8 @@ describe('seeded demo inspect contract', () => {
notion: { label: 'Notion', path: 'raw-sources/notion', pageCount: 8 },
},
generatedOutputs: {
semanticLayer: { path: 'semantic-layer/orbit_demo', manifestSourceCount: 6, fileCount: 6 },
knowledge: { path: 'knowledge/global', manifestPageCount: 10, fileCount: 10 },
semanticLayer: { path: 'semantic-layer', manifestSourceCount: 46, fileCount: 46 },
knowledge: { path: 'knowledge/global', manifestPageCount: 28, fileCount: 28 },
links: { path: 'links/provenance.json', manifestLinkCount: 23, linkCount: 23 },
reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 },
replays: { primaryPath: 'replays/replay.memory-flow.v1.json', latestPath: 'replays/latest.memory-flow.v1.json' },
@ -83,8 +83,8 @@ describe('seeded demo inspect contract', () => {
expect(output).toContain('dbt: 3 models, 8 source tables');
expect(output).toContain('BI: 5 explores, 2 dashboards');
expect(output).toContain('Notion: 8 pages');
expect(output).toContain('Semantic-layer sources: 6 manifest, 6 files');
expect(output).toContain('Knowledge pages: 10 manifest, 10 files');
expect(output).toContain('Semantic-layer sources: 46 manifest, 46 files');
expect(output).toContain('Knowledge pages: 28 manifest, 28 files');
expect(output).toContain('Evidence links: 23 manifest, 23 links');
expect(output).toContain('Report: reports/seeded-demo-report.json');
expect(output).toContain('Replay: replays/replay.memory-flow.v1.json');

View file

@ -71,12 +71,9 @@ const REQUIRED_SEEDED_PROJECT_PATHS = [
'state.sqlite',
'manifest.json',
join('replays', 'replay.memory-flow.v1.json'),
join('raw-sources', 'warehouse', 'accounts.csv'),
join('raw-sources', 'dbt', 'schema.yml'),
join('raw-sources', 'bi', 'revenue_exec.dashboard.lookml'),
join('raw-sources', 'notion', 'revenue-reporting-policy.md'),
join('semantic-layer', 'orbit_demo', 'accounts.yaml'),
join('knowledge', 'global', 'arr-contract-first.md'),
join('semantic-layer', 'dbt-main', 'mart_arr_daily.yaml'),
join('semantic-layer', 'postgres-warehouse', 'mart_account_activity.yaml'),
join('knowledge', 'global', 'orbit-company-overview.md'),
join('links', 'provenance.json'),
join('reports', 'seeded-demo-report.json'),
] as const;

View file

@ -19,11 +19,9 @@ describe('demo seeded mode', () => {
await expect(access(join(projectDir, 'demo.db'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'ktx.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'manifest.json'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'semantic-layer/orbit_demo/accounts.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'knowledge/global/arr-contract-first.md'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'raw-sources/dbt/schema.yml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'raw-sources/bi/revenue_exec.dashboard.lookml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'raw-sources/notion/revenue-reporting-policy.md'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'semantic-layer/dbt-main/mart_arr_daily.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'semantic-layer/postgres-warehouse/mart_account_activity.yaml'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'knowledge/global/orbit-company-overview.md'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'links/provenance.json'))).resolves.toBeUndefined();
await expect(access(join(projectDir, 'reports/seeded-demo-report.json'))).resolves.toBeUndefined();
});
@ -88,8 +86,8 @@ describe('demo seeded mode', () => {
it('SL YAML validates correctly', async () => {
await ensureSeededDemoProject({ projectDir, force: false });
const slYaml = await readFile(join(projectDir, 'semantic-layer/orbit_demo/accounts.yaml'), 'utf-8');
expect(slYaml).toContain('name: accounts');
const slYaml = await readFile(join(projectDir, 'semantic-layer/dbt-main/mart_arr_daily.yaml'), 'utf-8');
expect(slYaml).toContain('name: mart_arr_daily');
expect(slYaml).toContain('grain:');
expect(slYaml).toContain('columns:');
expect(slYaml).toContain('measures:');
@ -98,11 +96,11 @@ describe('demo seeded mode', () => {
it('wiki pages have valid frontmatter', async () => {
await ensureSeededDemoProject({ projectDir, force: false });
const wiki = await readFile(join(projectDir, 'knowledge/global/arr-contract-first.md'), 'utf-8');
const wiki = await readFile(join(projectDir, 'knowledge/global/orbit-company-overview.md'), 'utf-8');
expect(wiki).toContain('---');
expect(wiki).toContain('summary:');
expect(wiki).toContain('tags:');
expect(wiki).toContain('sl_refs:');
expect(wiki).toContain('refs:');
expect(wiki).toContain('usage_mode: auto');
});

View file

@ -0,0 +1,270 @@
import { describe, expect, it, vi } from 'vitest';
import type { KtxSetupAgentsResult } from './setup-agents.js';
import {
buildDemoReplayTimeline,
DEMO_REPLAY_TARGETS,
renderDemoAgentTransition,
renderDemoBanner,
renderDemoCardContent,
renderDemoCompletionSummary,
runDemoTour,
} 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 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('includes star headline', () => {
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, true));
expect(plain).toContain('★ KTX demo is ready');
});
it('shows manual instructions when agent not installed', () => {
const plain = stripAnsi(renderDemoCompletionSummary(projectDir, false));
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('postgres-warehouse');
expect(connectionIds).toContain('dbt-main');
expect(connectionIds).toContain('metabase-main');
expect(connectionIds).toContain('notion-main');
});
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');
}
});
});
describe('runDemoTour', () => {
function createMockIo() {
const chunks: string[] = [];
return {
io: {
stdout: { isTTY: true, columns: 80, write: (chunk: string) => { chunks.push(chunk); } },
stderr: { write: () => {} },
},
chunks,
};
}
it('returns 0 on successful tour with agent installed', async () => {
const { io, chunks } = createMockIo();
const mockAgents = vi.fn().mockResolvedValue({
status: 'ready',
projectDir: '/tmp/test',
installs: [{ target: 'claude-code', scope: 'project', mode: 'both' }],
} satisfies KtxSetupAgentsResult);
const navigation = vi.fn().mockResolvedValue('forward');
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
agents: mockAgents,
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
expect(mockAgents).toHaveBeenCalled();
// Should have rendered completion summary
const allOutput = chunks.join('');
expect(allOutput).toContain('agent is connected');
});
it('handles back navigation from first step by exiting', async () => {
const { io } = createMockIo();
const navigation = vi.fn().mockResolvedValue('back');
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
// Navigation called once for databases step, then exits
expect(navigation).toHaveBeenCalledTimes(1);
});
it('goes back from sources to databases', async () => {
const { io } = createMockIo();
let callCount = 0;
const navigation = vi.fn().mockImplementation(() => {
callCount++;
// First call (databases): forward
// Second call (sources): back
// Third call (databases again): back (exit)
if (callCount === 1) return Promise.resolve('forward');
return Promise.resolve('back');
});
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
expect(navigation).toHaveBeenCalledTimes(3);
});
it('handles agent step returning back', async () => {
const { io } = createMockIo();
let navCount = 0;
const navigation = vi.fn().mockImplementation(() => {
navCount++;
// Forward through databases, sources, context
// Then back from context (after agents returns back)
// Then back from sources, then back from databases (exit)
if (navCount <= 3) return Promise.resolve('forward');
return Promise.resolve('back');
});
const mockAgents = vi.fn().mockResolvedValue({
status: 'back',
projectDir: '/tmp/test',
} satisfies KtxSetupAgentsResult);
const result = await runDemoTour(
{ inputMode: 'auto' },
io,
{
agents: mockAgents,
waitForNavigation: navigation,
skipReplayAnimation: true,
ensureProject: vi.fn().mockResolvedValue({ projectDir: '/tmp/test' }),
},
);
expect(result).toBe(0);
});
});

View file

@ -0,0 +1,391 @@
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 { defaultDemoProjectDir, ensureSeededDemoProject } from './demo-assets.js';
import type { KtxPublicIngestPlanTarget } from './public-ingest.js';
import type { KtxSetupAgentsResult } from './setup-agents.js';
import { runKtxSetupAgentsStep } from './setup-agents.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,
failureText: 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[] = [
'',
`${cyan('★')} KTX demo is ready`,
'',
];
if (agentInstalled) {
lines.push(' Your agent is connected to a demo KTX project.');
} else {
lines.push(' Demo project created. Connect an agent to start using it:');
lines.push(` $ ${cyan(`ktx setup --agents --project-dir ${projectDir}`)}`);
}
lines.push(
'',
` ${dim('⚠')} This project is in a temporary directory and will be`,
' cleaned up by your system. To set up KTX with your own',
' data, run: ktx setup',
'',
` Project: ${projectDir}`,
);
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('postgres-warehouse', 'scan', 'postgres'),
],
contextSources: [
createDemoTarget('dbt-main', 'source-ingest', 'dbt'),
createDemoTarget('metabase-main', 'source-ingest', 'metabase'),
createDemoTarget('notion-main', 'source-ingest', 'notion'),
],
} as const;
export function buildDemoReplayTimeline(): DemoReplayEvent[] {
return [
// postgres-warehouse: scan
{ delayMs: 0, connectionId: 'postgres-warehouse', status: 'running', detailLine: null, summaryText: null },
{ delayMs: 1200, connectionId: 'postgres-warehouse', status: 'running', detailLine: '[50%] scanning tables...', summaryText: null },
{ delayMs: 2400, connectionId: 'postgres-warehouse', status: 'done', detailLine: null, summaryText: '56 tables scanned' },
// dbt-main
{ delayMs: 2400, connectionId: 'dbt-main', status: 'running', detailLine: null, summaryText: null },
{ delayMs: 3600, connectionId: 'dbt-main', status: 'running', detailLine: '[60%] ingesting models...', summaryText: null },
{ delayMs: 4400, connectionId: 'dbt-main', status: 'done', detailLine: null, summaryText: '34 models ingested' },
// metabase-main
{ delayMs: 4400, connectionId: 'metabase-main', status: 'running', detailLine: null, summaryText: null },
{ delayMs: 5600, connectionId: 'metabase-main', status: 'done', detailLine: null, summaryText: '80 cards ingested' },
// notion-main
{ delayMs: 5600, connectionId: 'notion-main', status: 'running', detailLine: null, summaryText: null },
{ delayMs: 6800, connectionId: 'notion-main', status: 'done', detailLine: null, summaryText: '9 pages ingested' },
];
}
function renderDemoContextCompletionSummary(): string {
const lines = [
'',
`${cyan('★')} KTX finished building context`,
'',
' KTX created:',
` ${cyan('📊')} 46 semantic layer definitions`,
` ${cyan('📝')} 28 knowledge pages`,
'',
` ${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);
}
// ---------------------------------------------------------------------------
// Demo tour orchestrator
// ---------------------------------------------------------------------------
type DemoStep = 'databases' | 'sources' | 'context' | 'agents';
const DEMO_STEPS: DemoStep[] = ['databases', 'sources', 'context', 'agents'];
export interface DemoTourDeps {
agents?: (args: Parameters<typeof runKtxSetupAgentsStep>[0], io: KtxCliIo) => Promise<KtxSetupAgentsResult>;
waitForNavigation?: (stdin?: NodeJS.ReadStream) => Promise<'forward' | 'back'>;
ensureProject?: typeof ensureSeededDemoProject;
skipReplayAnimation?: boolean;
}
export async function runDemoTour(
args: { inputMode: 'auto' | 'disabled' },
io: KtxCliIo,
deps: DemoTourDeps = {},
): Promise<number> {
const waitNav = deps.waitForNavigation ?? waitForDemoNavigation;
const ensureProject = deps.ensureProject ?? ensureSeededDemoProject;
const projectDir = defaultDemoProjectDir();
await ensureProject({ projectDir, force: false });
let stepIndex = 0;
while (stepIndex < DEMO_STEPS.length) {
const step = DEMO_STEPS[stepIndex]!;
let direction: 'forward' | 'back';
if (step === 'databases') {
direction = await renderDemoCard('Database connection', ['PostgreSQL — Orbit Analytics (56 tables, 2 schemas)'], io, undefined, waitNav);
} else if (step === 'sources') {
direction = await renderDemoCard('Context sources', ['dbt — 34 transformation models', 'Metabase — 80 dashboard cards', 'Notion — 9 knowledge pages'], io, undefined, waitNav);
} else if (step === 'context') {
io.stdout.write(renderDemoBanner() + '\n\n');
if (deps.skipReplayAnimation) {
direction = await waitNav();
} else {
direction = await runDemoContextReplay(io);
}
} else {
// agents step — real interactive
io.stdout.write(renderDemoAgentTransition() + '\n');
const agentsRunner = deps.agents ?? runKtxSetupAgentsStep;
const agentsResult = await agentsRunner(
{
projectDir,
inputMode: args.inputMode,
yes: false,
agents: true,
scope: 'project',
mode: 'both',
skipAgents: false,
},
io,
);
const agentInstalled = agentsResult.status === 'ready';
if (agentsResult.status === 'back') {
direction = 'back';
} else {
io.stdout.write(renderDemoCompletionSummary(projectDir, agentInstalled) + '\n');
return 0;
}
}
if (direction === 'back') {
if (stepIndex === 0) return 0;
stepIndex -= 1;
} else {
stepIndex += 1;
}
}
return 0;
}

View file

@ -6,8 +6,13 @@ import { promisify } from 'node:util';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
import { runDemoTour } from './setup-demo-tour.js';
import { readKtxSetupStatus, runKtxSetup } from './setup.js';
vi.mock('./setup-demo-tour.js', () => ({
runDemoTour: vi.fn(async () => 0),
}));
const execFileAsync = promisify(execFile);
function makeIo() {
@ -351,10 +356,10 @@ describe('setup status', () => {
expect(labels).toEqual([
'Set up KTX for my data',
'Check setup status',
'Try KTX with packaged demo data',
'Explore a pre-built KTX project',
'Exit',
]);
expect(labels.indexOf('Try KTX with packaged demo data')).toBe(labels.length - 2);
expect(labels.indexOf('Explore a pre-built KTX project')).toBe(labels.length - 2);
return 'exit';
});
const cancel = vi.fn();
@ -400,7 +405,7 @@ describe('setup status', () => {
'Create a new KTX project',
'Connect a coding agent to KTX',
'Check setup status',
'Try KTX with packaged demo data',
'Explore a pre-built KTX project',
'Exit',
]);
return 'exit';
@ -695,9 +700,8 @@ describe('setup status', () => {
);
});
it('runs the seeded demo when the first setup intent menu chooses packaged demo data', async () => {
it('runs the demo tour when the first setup intent menu chooses demo', async () => {
const testIo = makeIo();
const demo = vi.fn(async (_args: { projectDir: string }, _io: unknown) => 0);
await expect(
runKtxSetup(
@ -718,19 +722,15 @@ describe('setup status', () => {
showEntryMenu: true,
},
testIo.io,
{ entryMenuDeps: { prompts: { select: vi.fn(async () => 'demo'), cancel: vi.fn() } }, demo },
{ entryMenuDeps: { prompts: { select: vi.fn(async () => 'demo'), cancel: vi.fn() } } },
),
).resolves.toBe(0);
expect(demo).toHaveBeenCalledWith(
expect.objectContaining({
command: 'seeded',
outputMode: 'viz',
inputMode: 'auto',
}),
expect(runDemoTour).toHaveBeenCalledWith(
{ inputMode: 'auto' },
testIo.io,
expect.objectContaining({}),
);
expect(demo.mock.calls[0]?.[0].projectDir).toMatch(/ktx-demo-/);
});
it('creates a project through run mode when --new is selected', async () => {

View file

@ -3,8 +3,6 @@ import { join, resolve } from 'node:path';
import { cancel, isCancel, select } from '@clack/prompts';
import { loadKtxProject } from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import type { KtxDemoArgs } from './demo.js';
import { defaultDemoProjectDir } from './demo-assets.js';
import { formatSetupNextStepLines } from './next-steps.js';
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
import {
@ -148,7 +146,6 @@ export interface KtxSetupDeps {
removeAgents?: typeof removeKtxAgentInstall;
readyMenuDeps?: KtxSetupReadyMenuDeps;
entryMenuDeps?: KtxSetupEntryMenuDeps;
demo?: (args: KtxDemoArgs, io: KtxCliIo) => Promise<number>;
}
const SOURCE_DRIVERS = new Set(['dbt', 'metricflow', 'metabase', 'looker', 'lookml', 'notion']);
@ -200,13 +197,13 @@ async function runKtxSetupEntryMenu(
{ value: 'new-project', label: 'Create a new KTX project' },
{ value: 'agents', label: 'Connect a coding agent to KTX' },
{ value: 'status', label: 'Check setup status' },
{ value: 'demo', label: 'Try KTX with packaged demo data' },
{ value: 'demo', label: 'Explore a pre-built KTX project' },
{ value: 'exit', label: 'Exit' },
]
: [
{ value: 'setup', label: 'Set up KTX for my data' },
{ value: 'status', label: 'Check setup status' },
{ value: 'demo', label: 'Try KTX with packaged demo data' },
{ value: 'demo', label: 'Explore a pre-built KTX project' },
{ value: 'exit', label: 'Exit' },
];
const action = (await prompts.select({
@ -221,15 +218,11 @@ async function runKtxSetupDemoFromEntryMenu(
io: KtxCliIo,
deps: KtxSetupDeps,
): Promise<number> {
const runner = deps.demo ?? (await import('./demo.js')).runKtxDemo;
return await runner(
{
command: 'seeded',
projectDir: defaultDemoProjectDir(),
outputMode: 'viz',
inputMode: args.inputMode,
},
const { runDemoTour } = await import('./setup-demo-tour.js');
return await runDemoTour(
{ inputMode: args.inputMode },
io,
{ agents: deps.agents },
);
}