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'; import { TURN_STATES } from './src/turn-state.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. the status line renders the label for every non-idle state --- // The status line is driven by the connection-state enum, so the smoke test // walks the store through each busy state and checks the matching label. The // previous fixture hardcoded the free-text 'thinking…'. for (const [state, label] of [ [TURN_STATES.CONNECTING, 'connecting…'], [TURN_STATES.THINKING, 'thinking…'], [TURN_STATES.WORKING, 'working…'], [TURN_STATES.STREAMING, 'streaming…'], ]) { const s = createStore(); s.setBusy(true); s.setTurnState(state); const rendered = strip(renderToString(h(App, { store: s, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 })); assert.ok(rendered.includes(label), `${state} renders as ${label}`); assert.ok(!rendered.includes('› (idle)'), `${state} is not shown as idle`); } // The idle placeholder is the only thing shown when no turn is running. const idleStore = createStore(); idleStore.setBusy(false); const outIdle = strip(renderToString(h(App, { store: idleStore, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 })); assert.ok(outIdle.includes('› (idle)'), 'idle placeholder renders when not busy'); // --- 5. live reasoning streams into the scrollable history pane --- const reasoningStore = createStore(); reasoningStore.setBusy(true); reasoningStore.setTurnState(TURN_STATES.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 + connection states + streaming reasoning');