'use strict'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createElement as h } from 'react'; import { PassThrough } from 'node:stream'; import { render, renderToString } from 'ink'; import { App, getHistoryWindow } from '../src/ui/app.js'; import { createStore } from '../src/ui/store.js'; const stripAnsi = (value) => value.replace(/\x1b\[[0-9;]*m/g, ''); const historyLines = (output) => stripAnsi(output) .split('\n') .filter((line) => /^F\d+$/.test(line)); test('history stays bottom-anchored and scrolls through a long transcript', () => { const store = createStore(); store.setMeta({ model: 'test-model', workspace: '/test' }); store.prompt({ type: 'main', message: 'You' }); for (let i = 1; i <= 1000; i++) store.log(`F${i}`); const live = renderToString( h(App, { store, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }, ); const liveLines = historyLines(live); assert.equal(liveLines.at(-1), 'F1000', 'live view ends at the current tail'); assert.equal(liveLines.length, 14, 'window is sized to the available pane'); store.scrollBy(10); const scrolled = renderToString( h(App, { store, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }, ); const scrolledLines = historyLines(scrolled); assert.equal(scrolledLines.at(-1), 'F990', 'scrolling moves the window toward older lines'); assert.ok(!scrolledLines.includes('F1000'), 'scrolled view hides the live tail'); }); test('App translates SGR wheel reports to history scroll', async () => { const stdin = new PassThrough(); stdin.isTTY = true; stdin.setRawMode = () => {}; stdin.setEncoding = () => {}; stdin.ref = () => stdin; stdin.unref = () => stdin; const stdout = new PassThrough(); stdout.isTTY = true; stdout.columns = 80; stdout.rows = 24; const store = createStore(); store.prompt({ type: 'main', message: 'You' }); for (let i = 1; i <= 30; i++) store.log(`wheel line ${i}`); const instance = render(h(App, { store }), { stdin, stdout, alternateScreen: false, exitOnCtrlC: false, patchConsole: false, }); try { await new Promise((resolve) => setTimeout(resolve, 50)); stdin.push('\x1b[<64;88;20M'); await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(store.getState().scroll, 3); } finally { instance.unmount(); await instance.waitUntilExit(); } }); test('history window helper bounds the mounted line count', () => { const entries = Array.from({ length: 200 }, (_, i) => ({ id: i, text: `line ${i}`, })); const window = getHistoryWindow(entries, 0, 10); assert.equal(window.lines.length, 10); assert.equal(window.lines.at(-1).text, 'line 199'); assert.equal(window.start, 190); assert.equal(window.end, 200); }); test('runExternally returns the callback result through Ink suspension', async () => { const store = createStore(); store._suspend = async (callback) => { await callback(); }; let mouseTrackingRestores = 0; store._setMouseTracking = (on) => { if (on) mouseTrackingRestores++; }; assert.equal(await store.runExternally(() => 'saved editor text'), 'saved editor text'); assert.equal(mouseTrackingRestores, 1, 'mouse tracking is restored after suspension'); }); test('finalizing reasoning or submitting preserves the history offset', async () => { const store = createStore(); for (let i = 1; i <= 10; i++) store.log(`line ${i}`); store.scrollBy(3); store.setReasoningDraft('checking the files'); store.finalizeReasoning(); assert.equal(store.getState().scroll, 3, 'finalizing does not jump to the tail'); const prompt = store.prompt({ type: 'main', message: 'You' }); store.resolve('next question', '› You next question'); assert.equal(store.getState().scroll, 3, 'submitting does not jump to the tail'); await prompt; });