u
This commit is contained in:
@@ -49,7 +49,7 @@ const scrolled = buildMainStore();
|
|||||||
for (let i = 1; i <= 30; i++) scrolled.log(` filler line ${i}`);
|
for (let i = 1; i <= 30; i++) scrolled.log(` filler line ${i}`);
|
||||||
scrolled.scrollBy(10);
|
scrolled.scrollBy(10);
|
||||||
const outScrolled = strip(renderToString(h(App, { store: scrolled }), { columns: 80 }));
|
const outScrolled = strip(renderToString(h(App, { store: scrolled }), { columns: 80 }));
|
||||||
assert.ok(outScrolled.includes('(PgUp/PgDn'), 'header shows scroll badge when scrolled');
|
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 30'), 'scrolled view hides the live tail');
|
||||||
assert.ok(outScrolled.includes('filler line 20'), 'scrolled view shows older lines');
|
assert.ok(outScrolled.includes('filler line 20'), 'scrolled view shows older lines');
|
||||||
scrolled.resolve(null);
|
scrolled.resolve(null);
|
||||||
|
|||||||
99
src/fido.js
99
src/fido.js
@@ -38,7 +38,12 @@ import { printToolCall, emitGenericToolResult } from './tools/notify.js';
|
|||||||
import { COMMANDS, REASONING_LEVELS, findCommand } from './slash-commands.js';
|
import { COMMANDS, REASONING_LEVELS, findCommand } from './slash-commands.js';
|
||||||
import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js';
|
import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js';
|
||||||
import { mount, unmount, ui } from './ui/index.js';
|
import { mount, unmount, ui } from './ui/index.js';
|
||||||
import { getBlockContentWidth, renderAssistantBlock, renderToolBlock } from './ui/blocks.js';
|
import {
|
||||||
|
getBlockContentWidth,
|
||||||
|
renderAssistantBlock,
|
||||||
|
renderToolBlock,
|
||||||
|
renderUserBlock,
|
||||||
|
} from './ui/blocks.js';
|
||||||
|
|
||||||
// \u2500\u2500\u2500 Markdown \u2192 console renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
// \u2500\u2500\u2500 Markdown \u2192 console renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||||
|
|
||||||
@@ -130,6 +135,88 @@ function toolErrorLine(name, error) {
|
|||||||
return renderToolBlock(chalk.red(`\u2716 Tool error [${name}]: ${error}`));
|
return renderToolBlock(chalk.red(`\u2716 Tool error [${name}]: ${error}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RECALL_TOOL_LIMIT = 4000;
|
||||||
|
|
||||||
|
/** Convert the provider's content variants into text suitable for a block. */
|
||||||
|
function messageText(content) {
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content.map((part) => {
|
||||||
|
if (typeof part === 'string') return part;
|
||||||
|
if (part && typeof part === 'object') return part.text ?? part.content ?? '';
|
||||||
|
return '';
|
||||||
|
}).filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
if (content == null) return '';
|
||||||
|
if (typeof content === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(content) ?? String(content);
|
||||||
|
} catch {
|
||||||
|
return String(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipRecallText(text, limit = RECALL_TOOL_LIMIT) {
|
||||||
|
const value = String(text ?? '');
|
||||||
|
if (value.length <= limit) return value;
|
||||||
|
return value.slice(0, limit - 20) + '\n… [recalled message truncated] …';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one restored provider message in the same role blocks as live turns. */
|
||||||
|
function recalledMessageBlock(message) {
|
||||||
|
if (!message || typeof message !== 'object') return null;
|
||||||
|
|
||||||
|
const content = messageText(message.content);
|
||||||
|
if (message.role === 'user') {
|
||||||
|
if (!content.trim()) return null;
|
||||||
|
return renderUserBlock(`› You ${content.replace(/\n/g, '\n ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === 'assistant') {
|
||||||
|
if (content.trim()) return assistantBlock(content);
|
||||||
|
|
||||||
|
const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||||
|
if (calls.length === 0) return null;
|
||||||
|
const lines = calls.map((call) => {
|
||||||
|
const fn = call?.function || {};
|
||||||
|
let args = fn.arguments ?? '';
|
||||||
|
if (typeof args !== 'string') {
|
||||||
|
try {
|
||||||
|
args = JSON.stringify(args);
|
||||||
|
} catch {
|
||||||
|
args = String(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args = String(args).replace(/\n/g, ' ').trim();
|
||||||
|
if (args.length > 240) args = args.slice(0, 237) + '...';
|
||||||
|
return `⚙ Tool: ${fn.name || 'unknown'}${args ? `(${args})` : ''}`;
|
||||||
|
});
|
||||||
|
return renderToolBlock(chalk.dim(lines.join('\n')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.role === 'tool') {
|
||||||
|
if (!content.trim()) return null;
|
||||||
|
return renderToolBlock(chalk.dim(`↳ ${clipRecallText(content)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add the restored conversation to the visible transcript. */
|
||||||
|
function logRecalledMessages(restored) {
|
||||||
|
let shown = 0;
|
||||||
|
for (const message of restored) {
|
||||||
|
const block = recalledMessageBlock(message);
|
||||||
|
if (block) {
|
||||||
|
ui.log(block);
|
||||||
|
shown++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shown;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a bash_exec result as a complete tool block: status, stdout/stderr,
|
* Render a bash_exec result as a complete tool block: status, stdout/stderr,
|
||||||
* and any truncation notes share the same teal background and amber rail.
|
* and any truncation notes share the same teal background and amber rail.
|
||||||
@@ -379,16 +466,22 @@ async function main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const snap = suspends[choice];
|
const snap = suspends[choice];
|
||||||
|
if (!snap) {
|
||||||
|
ui.log(chalk.yellow(' Recall cancelled.'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const restored = Array.isArray(snap.messages) ? snap.messages : [];
|
const restored = Array.isArray(snap.messages) ? snap.messages : [];
|
||||||
|
const restoredMessages = restored.filter((m) => m && m.role !== 'system');
|
||||||
// Keep the freshly built system prompt (workspace may have changed)
|
// Keep the freshly built system prompt (workspace may have changed)
|
||||||
// and restore the rest of the conversation verbatim.
|
// and restore the rest of the conversation verbatim.
|
||||||
messages = [
|
messages = [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
...restored.filter((m) => m.role !== 'system'),
|
...restoredMessages,
|
||||||
];
|
];
|
||||||
const turns = messages.length - 1;
|
const turns = messages.length - 1;
|
||||||
ui.log(chalk.green(` \u25b6 Recalled: ${snap.description}`));
|
ui.log(chalk.green(` \u25b6 Recalled: ${snap.description}`));
|
||||||
ui.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored.`));
|
ui.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored; showing transcript below.`));
|
||||||
|
logRecalledMessages(restoredMessages);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,20 @@
|
|||||||
import {
|
import {
|
||||||
createElement as h,
|
createElement as h,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, Text, useApp, useInput, useWindowSize } from 'ink';
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
measureElement,
|
||||||
|
useApp,
|
||||||
|
useBoxMetrics,
|
||||||
|
useInput,
|
||||||
|
useWindowSize,
|
||||||
|
} from 'ink';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
|
||||||
import { MainInput } from './main-input.js';
|
import { MainInput } from './main-input.js';
|
||||||
@@ -171,11 +181,31 @@ export function getHistoryWindow(entries, scroll = 0, maxLines = MAX_VISIBLE_LIN
|
|||||||
}
|
}
|
||||||
|
|
||||||
function History({ entries, scroll }) {
|
function History({ entries, scroll }) {
|
||||||
const { lines } = getHistoryWindow(entries, scroll, MAX_VISIBLE_LINES);
|
// Yoga clips an oversized child from its top edge, even when the parent is
|
||||||
|
// bottom-anchored. Mount no more lines than the history pane can actually
|
||||||
|
// display; otherwise an overflowing 120-line window hides the live tail.
|
||||||
|
const ref = useRef(null);
|
||||||
|
const metrics = useBoxMetrics(ref);
|
||||||
|
const [layoutHeight, setLayoutHeight] = useState(0);
|
||||||
|
|
||||||
|
// useBoxMetrics updates when a sibling (for example, the file browser)
|
||||||
|
// changes the command-bar height. A layout effect also gives synchronous
|
||||||
|
// renderToString output a correctly sized initial window.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const node = ref.current;
|
||||||
|
if (!node) return;
|
||||||
|
const height = measureElement(node).height;
|
||||||
|
setLayoutHeight((previous) => previous === height ? previous : height);
|
||||||
|
});
|
||||||
|
|
||||||
|
const height = metrics.hasMeasured ? metrics.height : layoutHeight;
|
||||||
|
const maxLines = Math.max(1, Math.trunc(Number(height)) || 1);
|
||||||
|
const { lines } = getHistoryWindow(entries, scroll, maxLines);
|
||||||
const visibleText = lines.map((line) => line.text).join('\n');
|
const visibleText = lines.map((line) => line.text).join('\n');
|
||||||
return h(
|
return h(
|
||||||
Box,
|
Box,
|
||||||
{
|
{
|
||||||
|
ref,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -233,13 +263,23 @@ export function App({ store, sizeOverride }) {
|
|||||||
// external $EDITOR (for /paste) without corrupting the Ink frame.
|
// external $EDITOR (for /paste) without corrupting the Ink frame.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
store._suspend = (fn) => suspendTerminal(fn);
|
store._suspend = (fn) => suspendTerminal(fn);
|
||||||
setMouseTracking(true);
|
|
||||||
return () => {
|
return () => {
|
||||||
setMouseTracking(false);
|
|
||||||
store._suspend = null;
|
store._suspend = null;
|
||||||
};
|
};
|
||||||
}, [store, suspendTerminal]);
|
}, [store, suspendTerminal]);
|
||||||
|
|
||||||
|
// Mouse mode is terminal-global. Keep its lifecycle independent from the
|
||||||
|
// command-suspension effect so a re-render (or a prompt replacement) cannot
|
||||||
|
// accidentally leave tracking disabled.
|
||||||
|
useEffect(() => {
|
||||||
|
store._setMouseTracking = (on) => setMouseTracking(on);
|
||||||
|
setMouseTracking(true);
|
||||||
|
return () => {
|
||||||
|
store._setMouseTracking = null;
|
||||||
|
setMouseTracking(false);
|
||||||
|
};
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
// Global keys. Prompt components own arrows/Tab/Enter; the text input
|
// Global keys. Prompt components own arrows/Tab/Enter; the text input
|
||||||
// swallows printable chars and ignores PageUp/PageDown, so history scrolling
|
// swallows printable chars and ignores PageUp/PageDown, so history scrolling
|
||||||
// here never fights them.
|
// here never fights them.
|
||||||
|
|||||||
@@ -12,6 +12,16 @@
|
|||||||
|
|
||||||
const MOUSE_SEQUENCE_RE = /\x1b?\[<(\d+);\d+;\d+[Mm]/g;
|
const MOUSE_SEQUENCE_RE = /\x1b?\[<(\d+);\d+;\d+[Mm]/g;
|
||||||
const COMPLETE_MOUSE_SEQUENCE_RE = /^\x1b?\[<(\d+);\d+;\d+[Mm]$/;
|
const COMPLETE_MOUSE_SEQUENCE_RE = /^\x1b?\[<(\d+);\d+;\d+[Mm]$/;
|
||||||
|
// Some terminals fall back to the original X10 encoding even after SGR
|
||||||
|
// tracking is requested: ESC [ M <button> <x> <y>.
|
||||||
|
const X10_MOUSE_SEQUENCE_RE = /\x1b?\[M[\x20-\xff]{3}/g;
|
||||||
|
const COMPLETE_X10_MOUSE_SEQUENCE_RE = /^\x1b?\[M([\x20-\xff])([\x20-\xff])([\x20-\xff])$/;
|
||||||
|
const SGR_PREFIX_RE = /^\x1b?\[<(?:\d{0,3})(?:;\d{0,3}){0,2}$/;
|
||||||
|
const X10_PREFIX_RE = /^\x1b?\[M[\x20-\xff]{0,3}$/;
|
||||||
|
const SGR_FRAGMENT_AT_END_RE = /\x1b?\[<\d*(?:;\d*){0,2}$/;
|
||||||
|
const MOUSE_CONTINUATION_AT_END_RE = /\d*(?:;\d+){1,2}[Mm]$/;
|
||||||
|
let pendingWheelFragment = '';
|
||||||
|
let pendingStripFragment = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the scroll direction for an SGR mouse event.
|
* Return the scroll direction for an SGR mouse event.
|
||||||
@@ -22,24 +32,89 @@ const COMPLETE_MOUSE_SEQUENCE_RE = /^\x1b?\[<(\d+);\d+;\d+[Mm]$/;
|
|||||||
* request motion/drag reports, so a report carrying the wheel bit is safe to
|
* request motion/drag reports, so a report carrying the wheel bit is safe to
|
||||||
* treat as a wheel event even when a terminal adds modifier bits.
|
* treat as a wheel event even when a terminal adds modifier bits.
|
||||||
*/
|
*/
|
||||||
export function wheelDeltaFromInput(input) {
|
function normalizeMouseInput(value) {
|
||||||
if (typeof input !== 'string') return 0;
|
return value.startsWith('\x1b') ? value.slice(1) : value;
|
||||||
const match = COMPLETE_MOUSE_SEQUENCE_RE.exec(input);
|
}
|
||||||
if (!match) return 0;
|
|
||||||
|
|
||||||
const code = Number(match[1]);
|
function decodeCompleteMouse(value) {
|
||||||
|
const sgr = COMPLETE_MOUSE_SEQUENCE_RE.exec(value);
|
||||||
|
if (sgr) {
|
||||||
|
const code = Number(sgr[1]);
|
||||||
|
if ((code & 64) === 0) return 0;
|
||||||
|
return code & 1 ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x10 = COMPLETE_X10_MOUSE_SEQUENCE_RE.exec(value);
|
||||||
|
if (!x10) return null;
|
||||||
|
const code = x10[1].charCodeAt(0) - 32;
|
||||||
if ((code & 64) === 0) return 0;
|
if ((code & 64) === 0) return 0;
|
||||||
return code & 1 ? 1 : -1;
|
return code & 1 ? 1 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMousePrefix(value) {
|
||||||
|
return SGR_PREFIX_RE.test(value) || X10_PREFIX_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wheelDeltaFromInput(input) {
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
pendingWheelFragment = '';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeMouseInput(input);
|
||||||
|
if (pendingWheelFragment) {
|
||||||
|
const combined = pendingWheelFragment + normalized;
|
||||||
|
const delta = decodeCompleteMouse(combined);
|
||||||
|
if (delta !== null) {
|
||||||
|
pendingWheelFragment = '';
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
if (combined.length <= 32 && isMousePrefix(combined)) {
|
||||||
|
pendingWheelFragment = combined;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// The pending escape was not a mouse report after all. Continue with
|
||||||
|
// this ordinary input rather than carrying stale state forever.
|
||||||
|
pendingWheelFragment = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = decodeCompleteMouse(normalized);
|
||||||
|
if (delta !== null) return delta;
|
||||||
|
if (normalized.length <= 32 && isMousePrefix(normalized)) {
|
||||||
|
pendingWheelFragment = normalized;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** True when a value contains one or more SGR mouse reports. */
|
/** True when a value contains one or more SGR mouse reports. */
|
||||||
export function containsMouseSequence(value) {
|
export function containsMouseSequence(value) {
|
||||||
if (typeof value !== 'string') return false;
|
if (typeof value !== 'string') return false;
|
||||||
return /\x1b?\[<\d+;\d+;\d+[Mm]/.test(value);
|
return /\x1b?\[<\d+;\d+;\d+[Mm]/.test(value)
|
||||||
|
|| /\x1b?\[M[\x20-\xff]{3}/.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove SGR mouse reports from a value while preserving all other text. */
|
/** Remove SGR mouse reports from a value while preserving all other text. */
|
||||||
export function stripMouseSequences(value) {
|
export function stripMouseSequences(value) {
|
||||||
if (typeof value !== 'string') return value;
|
if (typeof value !== 'string') return value;
|
||||||
return value.replace(MOUSE_SEQUENCE_RE, '');
|
|
||||||
|
let cleaned = value;
|
||||||
|
if (pendingStripFragment) {
|
||||||
|
const continuation = cleaned.match(MOUSE_CONTINUATION_AT_END_RE);
|
||||||
|
if (continuation && continuation.index === cleaned.length - continuation[0].length) {
|
||||||
|
cleaned = cleaned.slice(0, continuation.index);
|
||||||
|
}
|
||||||
|
pendingStripFragment = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned = cleaned
|
||||||
|
.replace(MOUSE_SEQUENCE_RE, '')
|
||||||
|
.replace(X10_MOUSE_SEQUENCE_RE, '');
|
||||||
|
|
||||||
|
const prefix = cleaned.match(SGR_FRAGMENT_AT_END_RE);
|
||||||
|
if (prefix && prefix[0].length > 2) {
|
||||||
|
pendingStripFragment = prefix[0];
|
||||||
|
cleaned = cleaned.slice(0, prefix.index);
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function createStore() {
|
|||||||
setState({
|
setState({
|
||||||
entries,
|
entries,
|
||||||
reasoningDraft: '',
|
reasoningDraft: '',
|
||||||
scroll: clampScroll(state.scroll, entries.length),
|
scroll: clampScroll(state.scroll, entries),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ export function createStore() {
|
|||||||
setState({
|
setState({
|
||||||
...(entries === state.entries ? {} : { entries }),
|
...(entries === state.entries ? {} : { entries }),
|
||||||
prompt: null,
|
prompt: null,
|
||||||
scroll: clampScroll(state.scroll, entries.length),
|
scroll: clampScroll(state.scroll, entries),
|
||||||
});
|
});
|
||||||
p.resolve(value);
|
p.resolve(value);
|
||||||
},
|
},
|
||||||
@@ -217,9 +217,26 @@ export function createStore() {
|
|||||||
|
|
||||||
// Bridge to Ink's `suspendTerminal`, registered by <App> on mount.
|
// Bridge to Ink's `suspendTerminal`, registered by <App> on mount.
|
||||||
_suspend: null,
|
_suspend: null,
|
||||||
runExternally(fn) {
|
// Bridge to the App's terminal mouse-mode controller.
|
||||||
|
_setMouseTracking: null,
|
||||||
|
async runExternally(fn) {
|
||||||
if (!store._suspend) throw new Error('UI is not mounted');
|
if (!store._suspend) throw new Error('UI is not mounted');
|
||||||
return store._suspend(fn);
|
|
||||||
|
// Ink's suspendTerminal callback intentionally resolves with no
|
||||||
|
// value. Capture the callback result here so callers such as
|
||||||
|
// /paste receive the text written by the external editor.
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
await store._suspend(async () => {
|
||||||
|
result = await fn();
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
// Exiting the alternate screen for an external editor can cause
|
||||||
|
// some terminals to reset mouse tracking. Restore it when Fido
|
||||||
|
// takes the terminal back.
|
||||||
|
store._setMouseTracking?.(true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ test('decodes SGR wheel direction', () => {
|
|||||||
assert.equal(wheelDeltaFromInput('[<97;88;20M'), 1);
|
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', () => {
|
test('ignores non-wheel and incomplete mouse input', () => {
|
||||||
assert.equal(wheelDeltaFromInput('[<0;1;1M'), 0);
|
assert.equal(wheelDeltaFromInput('[<0;1;1M'), 0);
|
||||||
assert.equal(wheelDeltaFromInput('[<64;1;1'), 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(containsMouseSequence(value), true);
|
||||||
assert.equal(stripMouseSequences(value), 'beforeafter');
|
assert.equal(stripMouseSequences(value), 'beforeafter');
|
||||||
assert.equal(stripMouseSequences('plain text'), 'plain text');
|
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
115
test/ui.test.js
Normal 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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user