import { createElement as h } from 'react'; import { renderToString } from 'ink'; import assert from 'node:assert/strict'; import chalk from 'chalk'; import { createStore } from './src/ui/store.js'; import { App } from './src/ui/app.js'; // --- fixture: exercises header, history, command bar, token footer --- function buildMainStore() { const store = createStore(); const RESET = '\x1b[0m', BG = '\x1b[100m', BGCLOSE = '\x1b[49m'; const paint = (text, width = 40) => text.split('\n').map((l) => { const vis = l.replace(/\x1b\[[0-9;]*m/g, '').length; const pad = l + ' '.repeat(Math.max(0, width - vis)); return BG + pad.replace(/\x1b\[0m/g, RESET + BG) + BGCLOSE; }).join('\n'); store.setMeta({ model: 'smoke-model', workspace: '/tmp/ws', reasoning: 'low' }); store.log(chalk.cyan.bold(' [banner line]')); store.log('\n ' + chalk.green.bold('Assistant') + '\n ' + '-'.repeat(20) + '\n' + paint(' ' + chalk.bold('hello') + ' world')); store.addUsage({ input: 1234, output: 567, cached: 100, cost: 0.01234 }); store.prompt({ type: 'main', message: 'You', prefill: '/rea' }); return store; } // --- 1. main pane layout --- const store = buildMainStore(); const out = renderToString(h(App, { store }), { columns: 80 }); console.log('----- JSON -----'); console.log(JSON.stringify(out)); console.log('----- VISIBLE -----'); console.log(out); // Strip ANSI for structural assertions. const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ''); const vis = strip(out); assert.ok(vis.includes('FIDO'), 'header chrome renders'); assert.ok(vis.includes('smoke-model'), 'header shows model'); assert.ok(vis.includes('[banner line]'), 'history renders'); assert.ok(vis.includes('hello world'), 'history renders painted block'); assert.ok(vis.includes('/reasoning'), 'command bar shows slash autocomplete'); assert.ok(vis.includes('in=1,234'), 'footer shows token totals'); assert.ok(vis.includes('cost=$0.01234'), 'footer shows cost'); assert.ok(vis.includes('PgUp/PgDn'), 'footer shows scroll hints'); store.resolve(null); // settle the prompt promise so the process can exit // --- 2. scrolled history hides the live tail, shows the scroll badge --- const scrolled = buildMainStore(); for (let i = 1; i <= 30; i++) scrolled.log(` filler line ${i}`); scrolled.scrollBy(10); const outScrolled = strip(renderToString(h(App, { store: scrolled }), { columns: 80 })); assert.ok(outScrolled.includes('▲ 10 lines'), 'header shows scroll badge when scrolled'); assert.ok(!outScrolled.includes('filler line 30'), 'scrolled view hides the live tail'); assert.ok(outScrolled.includes('filler line 20'), 'scrolled view shows older lines'); scrolled.resolve(null); // Wheel decoding is intentionally exercised through the same store movement // semantics as the terminal listener: up increases the offset, down decreases. const wheel = buildMainStore(); for (let i = 1; i <= 30; i++) wheel.log(` wheel line ${i}`); wheel.scrollBy(3); assert.equal(wheel.getState().scroll, 3, 'wheel-up delta moves toward older history'); wheel.scrollBy(-3); assert.equal(wheel.getState().scroll, 0, 'wheel-down delta returns to the live tail'); wheel.resolve(null); // --- 3. tiny-terminal fallback (rows < 12): plain column, no clipping --- // NOTE: renderToString only accepts `columns`, so drive the fallback through // App's `sizeOverride` test seam instead of render options. const tiny = buildMainStore(); const outTiny = strip(renderToString(h(App, { store: tiny, sizeOverride: { columns: 80, rows: 8 } }), { columns: 80 })); assert.ok(outTiny.includes('[banner line]'), 'tiny fallback still renders history'); assert.ok(outTiny.includes('in=1,234'), 'tiny fallback still shows totals'); assert.ok(!outTiny.includes('PgUp/PgDn history'), 'tiny fallback drops fullscreen key hints'); tiny.resolve(null); // --- 4. live reasoning streams into the scrollable history pane --- const reasoningStore = createStore(); reasoningStore.setBusy(true); reasoningStore.setStatus('thinking…'); reasoningStore.setReasoningDraft('Analyzing user question\nChecking relevant files'); const outLiveReasoning = strip(renderToString(h(App, { store: reasoningStore, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 })); assert.ok(outLiveReasoning.includes('🧠 Reasoning (thinking…)'), 'history pane streams live reasoning with thinking header'); assert.ok(outLiveReasoning.includes('Analyzing user question'), 'history pane shows line 1 of reasoning'); assert.ok(outLiveReasoning.includes('Checking relevant files'), 'history pane shows line 2 of reasoning'); // Finalizing promotes to permanent entry without "(thinking…)" reasoningStore.finalizeReasoning(); const outFinalReasoning = strip(renderToString(h(App, { store: reasoningStore, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 })); assert.ok(outFinalReasoning.includes('🧠 Reasoning'), 'finalized reasoning has header'); assert.ok(!outFinalReasoning.includes('🧠 Reasoning (thinking…)'), 'finalized reasoning drops thinking indicator from header'); assert.ok(outFinalReasoning.includes('Analyzing user question'), 'finalized reasoning preserves content'); console.log('\nSMOKE-OK: header/history/command-bar/footer + scroll + tiny fallback + streaming reasoning');