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

@@ -38,7 +38,12 @@ import { printToolCall, emitGenericToolResult } from './tools/notify.js';
import { COMMANDS, REASONING_LEVELS, findCommand } from './slash-commands.js';
import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.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
@@ -130,6 +135,88 @@ function toolErrorLine(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,
* and any truncation notes share the same teal background and amber rail.
@@ -379,16 +466,22 @@ async function main() {
continue;
}
const snap = suspends[choice];
if (!snap) {
ui.log(chalk.yellow(' Recall cancelled.'));
continue;
}
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)
// and restore the rest of the conversation verbatim.
messages = [
{ role: 'system', content: systemPrompt },
...restored.filter((m) => m.role !== 'system'),
...restoredMessages,
];
const turns = messages.length - 1;
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;
}

View File

@@ -29,10 +29,20 @@
import {
createElement as h,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} 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 { MainInput } from './main-input.js';
@@ -171,11 +181,31 @@ export function getHistoryWindow(entries, scroll = 0, maxLines = MAX_VISIBLE_LIN
}
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');
return h(
Box,
{
ref,
flexGrow: 1,
flexShrink: 1,
flexDirection: 'column',
@@ -233,13 +263,23 @@ export function App({ store, sizeOverride }) {
// external $EDITOR (for /paste) without corrupting the Ink frame.
useEffect(() => {
store._suspend = (fn) => suspendTerminal(fn);
setMouseTracking(true);
return () => {
setMouseTracking(false);
store._suspend = null;
};
}, [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
// swallows printable chars and ignores PageUp/PageDown, so history scrolling
// here never fights them.

View File

@@ -12,6 +12,16 @@
const MOUSE_SEQUENCE_RE = /\x1b?\[<(\d+);\d+;\d+[Mm]/g;
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.
@@ -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
* treat as a wheel event even when a terminal adds modifier bits.
*/
export function wheelDeltaFromInput(input) {
if (typeof input !== 'string') return 0;
const match = COMPLETE_MOUSE_SEQUENCE_RE.exec(input);
if (!match) return 0;
function normalizeMouseInput(value) {
return value.startsWith('\x1b') ? value.slice(1) : value;
}
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;
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. */
export function containsMouseSequence(value) {
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. */
export function stripMouseSequences(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;
}

View File

@@ -167,7 +167,7 @@ export function createStore() {
setState({
entries,
reasoningDraft: '',
scroll: clampScroll(state.scroll, entries.length),
scroll: clampScroll(state.scroll, entries),
});
},
@@ -190,7 +190,7 @@ export function createStore() {
setState({
...(entries === state.entries ? {} : { entries }),
prompt: null,
scroll: clampScroll(state.scroll, entries.length),
scroll: clampScroll(state.scroll, entries),
});
p.resolve(value);
},
@@ -217,9 +217,26 @@ export function createStore() {
// Bridge to Ink's `suspendTerminal`, registered by <App> on mount.
_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');
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);
}
},
};