This commit is contained in:
seb
2026-09-25 06:26:59 +02:00
parent b7ef321e05
commit 2efd9bda39
7 changed files with 383 additions and 19 deletions

View File

@@ -17,6 +17,14 @@ test('decodes SGR wheel direction', () => {
assert.equal(wheelDeltaFromInput('[<97;88;20M'), 1);
});
test('decodes legacy X10 wheel direction', () => {
const x10Up = `\x1b[M${String.fromCharCode(96)}!#`;
const x10Down = `\x1b[M${String.fromCharCode(97)}!#`;
assert.equal(wheelDeltaFromInput(x10Up), -1);
assert.equal(wheelDeltaFromInput(x10Down), 1);
assert.equal(wheelDeltaFromInput(x10Up.slice(1)), -1, 'accepts Ink-stripped ESC');
});
test('ignores non-wheel and incomplete mouse input', () => {
assert.equal(wheelDeltaFromInput('[<0;1;1M'), 0);
assert.equal(wheelDeltaFromInput('[<64;1;1'), 0);
@@ -28,4 +36,20 @@ test('removes mouse reports without dropping surrounding text', () => {
assert.equal(containsMouseSequence(value), true);
assert.equal(stripMouseSequences(value), 'beforeafter');
assert.equal(stripMouseSequences('plain text'), 'plain text');
const x10 = `\x1b[M${String.fromCharCode(96)}!#`;
assert.equal(containsMouseSequence(x10), true);
assert.equal(stripMouseSequences(`before${x10}after`), 'beforeafter');
});
test('reassembles an SGR wheel report split across input events', () => {
assert.equal(wheelDeltaFromInput('[<64;'), 0);
assert.equal(wheelDeltaFromInput('88;20M'), -1);
// A normal key clears any stale partial sequence state.
assert.equal(wheelDeltaFromInput('x'), 0);
});
test('removes split SGR fragments from a text-input value', () => {
assert.equal(stripMouseSequences('before[<64;'), 'before');
assert.equal(stripMouseSequences('before88;20M'), 'before');
});

115
test/ui.test.js Normal file
View File

@@ -0,0 +1,115 @@
'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;
});