u
This commit is contained in:
29
.ui-order.mjs
Normal file
29
.ui-order.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createElement as h, useState } from 'react';
|
||||
import { render, Box, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
const order = [];
|
||||
function Gate() {
|
||||
useInput((input, key) => {
|
||||
if (key.ctrl && input === 'o') order.push('gate');
|
||||
});
|
||||
return null;
|
||||
}
|
||||
function App() {
|
||||
const [v, setV] = useState('');
|
||||
return h(
|
||||
Box,
|
||||
null,
|
||||
h(Gate, { key: 'g' }),
|
||||
h(TextInput, {
|
||||
key: 't',
|
||||
value: v,
|
||||
onChange: (nv) => { order.push('onChange:' + JSON.stringify(nv)); setV(nv); },
|
||||
}),
|
||||
);
|
||||
}
|
||||
render(h(App), { exitOnCtrlC: false });
|
||||
setTimeout(() => {
|
||||
process.stdout.write('\nORDER=' + JSON.stringify(order) + '\n');
|
||||
process.exit(0);
|
||||
}, 1200);
|
||||
9
.ui-pty.mjs
Normal file
9
.ui-pty.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
import { mount, unmount, ui } from './src/ui/index.js';
|
||||
|
||||
mount();
|
||||
ui.log('READY');
|
||||
const v = await ui.ask({ message: 'You', prefill: '' });
|
||||
ui.log('GOT:' + JSON.stringify(v));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await unmount();
|
||||
process.exit(0);
|
||||
18
.ui-pty2.mjs
Normal file
18
.ui-pty2.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { mount, unmount, ui } from './src/ui/index.js';
|
||||
|
||||
mount();
|
||||
const c = await ui.confirm({ message: 'Keep?', default: true });
|
||||
ui.log('CONFIRM:' + c);
|
||||
const s = await ui.select({
|
||||
message: 'Pick',
|
||||
choices: [
|
||||
{ name: 'a', value: 'A' },
|
||||
{ name: 'b', value: 'B' },
|
||||
{ name: 'c', value: 'C' },
|
||||
],
|
||||
});
|
||||
ui.log('SELECT:' + s);
|
||||
const t = await ui.input({ message: 'Text' });
|
||||
ui.log('INPUT:' + t);
|
||||
await unmount();
|
||||
process.exit(0);
|
||||
95
.ui-smoke.mjs
Normal file
95
.ui-smoke.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
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';
|
||||
|
||||
// --- 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('(PgUp/PgDn'), '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. live reasoning streams into the scrollable history pane ---
|
||||
const reasoningStore = createStore();
|
||||
reasoningStore.setBusy(true);
|
||||
reasoningStore.setStatus('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 + streaming reasoning');
|
||||
1043
package-lock.json
generated
1043
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,14 +11,17 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^10.3.2",
|
||||
"@inquirer/prompts": "^7.5.1",
|
||||
"@tavily/core": "^0.7.13",
|
||||
"chalk": "^5.4.1",
|
||||
"dotenv": "^18.0.2",
|
||||
"ignore": "^7.0.10",
|
||||
"ink": "^7.1.1",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"llmorbiter": "file:../llmorbiter",
|
||||
"marked": "^15.0.12",
|
||||
"marked-terminal": "^7.3.0"
|
||||
"marked-terminal": "^7.3.0",
|
||||
"react": "^19.3.0",
|
||||
"string-width": "^4.2.3",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Slash-command autocomplete prompt.
|
||||
*
|
||||
* An `input`-style single-line prompt built on @inquirer/core. It behaves like
|
||||
* a normal text input, but as soon as the buffer starts with "/" (and no
|
||||
* arguments have been typed yet) it renders the matching slash commands as
|
||||
* inline help below the prompt.
|
||||
*
|
||||
* ↑ / ↓ move the highlight between matches
|
||||
* Tab complete the highlighted command into the buffer
|
||||
* Enter submit the buffer (like a normal input)
|
||||
*
|
||||
* The filter term is frozen while the menu is open, so completing a command
|
||||
* with Tab does not collapse the list — repeated ↑/↓ + Tab still works.
|
||||
*
|
||||
* It also hosts an in-prompt **file browser** (opened with Ctrl+O) with two
|
||||
* modes: browse and line-select. The user picks a whole file (`@path`) or a
|
||||
* line range (`@path:start-end`), and the reference is spliced into the draft
|
||||
* at the caret. See `file-browser.js` for the pure helpers and PLAN.md for the
|
||||
* design.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
createPrompt,
|
||||
useState,
|
||||
useKeypress,
|
||||
useEffect,
|
||||
useRef,
|
||||
usePrefix,
|
||||
isEnterKey,
|
||||
isTabKey,
|
||||
isUpKey,
|
||||
isDownKey,
|
||||
isBackspaceKey,
|
||||
makeTheme,
|
||||
} from '@inquirer/core';
|
||||
|
||||
import { COMMANDS, filterCommands } from './slash-commands.js';
|
||||
import {
|
||||
listEntries,
|
||||
readLines,
|
||||
formatMention,
|
||||
normalizeRange,
|
||||
windowStart,
|
||||
parentOf,
|
||||
} from './file-browser.js';
|
||||
|
||||
const MAX_SUGGESTIONS = 6;
|
||||
const NAME_WIDTH = Math.max(...COMMANDS.map((c) => c.name.length));
|
||||
const BROWSE_WINDOW = 12; // visible rows in the file browser
|
||||
const LINE_WINDOW = 18; // visible rows in the line selector
|
||||
|
||||
/** Show the inline menu only while the first word is being typed (no space yet). */
|
||||
function termFrom(line) {
|
||||
if (typeof line !== 'string' || !line.startsWith('/')) return null;
|
||||
if (line.includes(' ')) return null;
|
||||
return line.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace readline's actual edit buffer, not just the visible terminal line.
|
||||
* `rl.clearLine()` only erases terminal output; using it before `rl.write()`
|
||||
* leaves the old buffer in place and turns a completion such as `/p` into
|
||||
* `/p/paste` internally.
|
||||
*/
|
||||
function replaceBuffer(rl, nextValue) {
|
||||
rl.write(null, { ctrl: true, name: 'a' });
|
||||
rl.write(null, { ctrl: true, name: 'k' });
|
||||
rl.write(nextValue);
|
||||
}
|
||||
|
||||
export const autocompleteInput = createPrompt((config, done) => {
|
||||
const theme = makeTheme({}, config.theme);
|
||||
const initial = typeof config.default === 'string' ? config.default : '';
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [value, setValue] = useState(initial);
|
||||
const [term, setTerm] = useState(termFrom(initial)); // frozen filter term while the menu is open
|
||||
const [active, setActive] = useState(0); // highlighted suggestion index
|
||||
|
||||
// ── File-browser state ──────────────────────────────────────────────
|
||||
// mode: 'input' (normal editing) | 'browse' (file picker) | 'lines' (line selector)
|
||||
const [mode, setMode] = useState('input');
|
||||
const [cwd, setCwd] = useState(''); // browse dir, relative to ROOT_DIR ('' = root)
|
||||
const [entries, setEntries] = useState([]); // listing for `cwd`
|
||||
const [browseActive, setBrowseActive] = useState(0);
|
||||
const [selFile, setSelFile] = useState(null); // file chosen in 'lines' mode
|
||||
const [fileLines, setFileLines] = useState([]);
|
||||
const [fileBinary, setFileBinary] = useState(false);
|
||||
const [cursor, setCursor] = useState(0); // current line index in 'lines' mode
|
||||
const [anchor, setAnchor] = useState(null); // range-selection anchor (line index) or null
|
||||
const [notice, setNotice] = useState(''); // dim status/notice line
|
||||
|
||||
// The draft (and caret) captured when the browser opened, so a cancel can
|
||||
// restore the untouched message and an insert knows where to splice.
|
||||
const draftRef = useRef('');
|
||||
const caretRef = useRef(0);
|
||||
// Mirror `mode` into a ref so the SIGINT guard (installed once) sees it.
|
||||
const modeRef = useRef('input');
|
||||
modeRef.current = mode;
|
||||
|
||||
const prefix = usePrefix({ status, theme });
|
||||
const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS);
|
||||
const count = suggestions.length;
|
||||
|
||||
// ── Browser helpers ─────────────────────────────────────────────────
|
||||
const openDir = (rel) => {
|
||||
setCwd(rel);
|
||||
setEntries(listEntries(rel));
|
||||
setBrowseActive(0);
|
||||
setNotice('');
|
||||
};
|
||||
|
||||
const openFile = (rel) => {
|
||||
const { lines, binary, error } = readLines(rel);
|
||||
if (error) {
|
||||
setNotice(`could not read ${rel}`);
|
||||
setEntries(listEntries(cwd)); // refresh in case it moved/vanished
|
||||
return;
|
||||
}
|
||||
setSelFile(rel);
|
||||
setFileLines(lines);
|
||||
setFileBinary(binary);
|
||||
setCursor(0);
|
||||
setAnchor(null);
|
||||
setNotice(binary ? 'binary file — whole-file mention only' : '');
|
||||
setMode('lines');
|
||||
};
|
||||
|
||||
const cancelToInput = (rl) => {
|
||||
replaceBuffer(rl, draftRef.current);
|
||||
const back = draftRef.current.length - caretRef.current;
|
||||
for (let i = 0; i < back; i++) rl.write(null, { ctrl: true, name: 'b' });
|
||||
setMode('input');
|
||||
setNotice('');
|
||||
};
|
||||
|
||||
// Splice `text` into the captured draft at the captured caret, leaving the
|
||||
// caret just after the inserted text, then return to normal input mode.
|
||||
const insertMention = (rl, text) => {
|
||||
const draft = draftRef.current;
|
||||
const pos = Math.min(caretRef.current, draft.length);
|
||||
const before = draft.slice(0, pos);
|
||||
const after = draft.slice(pos);
|
||||
const pad = before.length && !/\s$/.test(before) ? ' ' : '';
|
||||
const next = before + pad + text + after;
|
||||
replaceBuffer(rl, next);
|
||||
for (let i = 0; i < after.length; i++) rl.write(null, { ctrl: true, name: 'b' });
|
||||
setValue(next);
|
||||
setTerm(termFrom(next));
|
||||
setActive(0);
|
||||
setMode('input');
|
||||
setNotice('');
|
||||
};
|
||||
|
||||
useKeypress((key, rl) => {
|
||||
if (status !== 'idle') return;
|
||||
|
||||
// ── Browse mode ─────────────────────────────────────────────────
|
||||
if (mode === 'browse') {
|
||||
const n = entries.length;
|
||||
if (isUpKey(key)) {
|
||||
if (n) setBrowseActive((browseActive - 1 + n) % n);
|
||||
return;
|
||||
}
|
||||
if (isDownKey(key)) {
|
||||
if (n) setBrowseActive((browseActive + 1) % n);
|
||||
return;
|
||||
}
|
||||
if (isEnterKey(key)) {
|
||||
const entry = entries[browseActive];
|
||||
if (!entry) return;
|
||||
if (entry.type === 'f') openFile(entry.rel);
|
||||
else openDir(entry.rel); // dir or synthetic '..'
|
||||
return;
|
||||
}
|
||||
if (isTabKey(key)) {
|
||||
const entry = entries[browseActive];
|
||||
if (entry && entry.type === 'f') insertMention(rl, formatMention(entry.rel));
|
||||
return;
|
||||
}
|
||||
if (isBackspaceKey(key)) {
|
||||
if (cwd) openDir(parentOf(cwd));
|
||||
return;
|
||||
}
|
||||
if (key.name === 'escape') cancelToInput(rl);
|
||||
return; // swallow every other key so the draft is untouched
|
||||
}
|
||||
|
||||
// ── Line-select mode ────────────────────────────────────────────
|
||||
if (mode === 'lines') {
|
||||
const n = fileLines.length;
|
||||
if (isUpKey(key)) {
|
||||
setCursor(Math.max(0, cursor - 1));
|
||||
return;
|
||||
}
|
||||
if (isDownKey(key)) {
|
||||
setCursor(Math.max(0, Math.min(n - 1, cursor + 1)));
|
||||
return;
|
||||
}
|
||||
if (!fileBinary && n > 0) {
|
||||
if (key.name === 'v') { setAnchor(anchor === null ? cursor : null); return; }
|
||||
if (key.name === 'g') { setCursor(0); return; }
|
||||
if (key.name === 'G') { setCursor(n - 1); return; }
|
||||
}
|
||||
if (isEnterKey(key)) {
|
||||
let text;
|
||||
if (anchor !== null && n > 0) {
|
||||
const { start, end } = normalizeRange(anchor, cursor);
|
||||
text = formatMention(selFile, start, end);
|
||||
} else {
|
||||
text = formatMention(selFile);
|
||||
}
|
||||
insertMention(rl, text);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'escape' || isBackspaceKey(key)) {
|
||||
setMode('browse');
|
||||
setNotice('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Input mode (normal editing) ─────────────────────────────────
|
||||
// Ctrl+O opens the file browser at the workspace root.
|
||||
if (key.ctrl && key.name === 'o') {
|
||||
draftRef.current = rl.line;
|
||||
caretRef.current = rl.cursor;
|
||||
openDir('');
|
||||
setMode('browse');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterKey(key)) {
|
||||
const answer = value || rl.line;
|
||||
setValue(answer);
|
||||
setStatus('done');
|
||||
done(answer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Move the highlight within the open menu. Arrow keys do not change
|
||||
// readline's text buffer, so no line clearing or rewriting is needed.
|
||||
if (count > 0 && (isUpKey(key) || isDownKey(key))) {
|
||||
const delta = isUpKey(key) ? -1 : 1;
|
||||
setActive((active + delta + count) % count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete the highlighted command into both the displayed value and
|
||||
// readline's real edit buffer.
|
||||
if (isTabKey(key)) {
|
||||
if (count === 0) return;
|
||||
const pick = suggestions[active % count];
|
||||
replaceBuffer(rl, pick.name);
|
||||
setValue(pick.name);
|
||||
return; // keep `term` frozen so the menu stays open
|
||||
}
|
||||
|
||||
// Ordinary editing: mirror the readline buffer and refresh the menu.
|
||||
const line = rl.line;
|
||||
setValue(line);
|
||||
setTerm(termFrom(line));
|
||||
setActive(0);
|
||||
});
|
||||
|
||||
// Prefill the editable readline buffer (and mirror it into `value`) when an
|
||||
// initial value is supplied — used to restore a message that a Ctrl+C
|
||||
// cancelled back into the prompt so it can be edited and resent.
|
||||
useEffect((rl) => {
|
||||
if (initial) rl.write(initial);
|
||||
}, []);
|
||||
|
||||
// While browsing, Ctrl+C should return to the prompt, not abort the whole
|
||||
// prompt. @inquirer/core installs its own SIGINT listener that rejects the
|
||||
// prompt; wrap it so it only fires when we're in normal input mode.
|
||||
useEffect((rl) => {
|
||||
const existing = rl.listeners('SIGINT');
|
||||
rl.removeAllListeners('SIGINT');
|
||||
const guarded = (...args) => {
|
||||
if (modeRef.current === 'input') existing.forEach((fn) => fn.apply(rl, args));
|
||||
};
|
||||
rl.on('SIGINT', guarded);
|
||||
return () => {
|
||||
rl.removeAllListeners('SIGINT');
|
||||
existing.forEach((fn) => rl.on('SIGINT', fn));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const message = theme.style.message(config.message);
|
||||
const formattedValue = status === 'done' ? theme.style.answer(value) : value;
|
||||
|
||||
// ── File-browser view ───────────────────────────────────────────────
|
||||
if (mode === 'browse') {
|
||||
const n = entries.length;
|
||||
const start = windowStart(browseActive, n, BROWSE_WINDOW);
|
||||
const end = Math.min(n, start + BROWSE_WINDOW);
|
||||
const rows = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
const e = entries[i];
|
||||
const marker = i === browseActive ? chalk.cyan('❯') : ' ';
|
||||
let label;
|
||||
if (e.type === 'parent') label = chalk.dim('../');
|
||||
else if (e.type === 'd') label = chalk.bold.cyan(`${e.name}/`);
|
||||
else label = e.name;
|
||||
rows.push(` ${marker} ${label}`);
|
||||
}
|
||||
const where = cwd ? `${cwd}/` : '(workspace root)';
|
||||
const out = [
|
||||
` ${chalk.bold.cyan('📁 File browser')} ${chalk.dim(where)}`,
|
||||
rows.length ? rows.join('\n') : chalk.dim(' (empty directory)'),
|
||||
];
|
||||
if (notice) out.push(chalk.dim(` ${notice}`));
|
||||
out.push(chalk.dim(' ↑/↓ move · Enter open · Tab mention · Backspace up · Esc cancel'));
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ── Line-selector view ──────────────────────────────────────────────
|
||||
if (mode === 'lines') {
|
||||
const n = fileLines.length;
|
||||
const out = [` ${chalk.bold.cyan(selFile)} ${chalk.dim('— select lines')}`];
|
||||
if (fileBinary) {
|
||||
out.push(chalk.dim(' (binary file — Enter mentions the whole file)'));
|
||||
} else if (n === 0) {
|
||||
out.push(chalk.dim(' (empty file — Enter mentions the whole file)'));
|
||||
} else {
|
||||
const start = windowStart(cursor, n, LINE_WINDOW);
|
||||
const end = Math.min(n, start + LINE_WINDOW);
|
||||
const width = String(n).length;
|
||||
const lo = anchor === null ? null : Math.min(anchor, cursor);
|
||||
const hi = anchor === null ? null : Math.max(anchor, cursor);
|
||||
const rows = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
const cur = i === cursor ? chalk.cyan('❯') : ' ';
|
||||
const mark = lo !== null && i >= lo && i <= hi ? chalk.cyan('│') : ' ';
|
||||
const num = chalk.dim(String(i + 1).padStart(width));
|
||||
const text = fileLines[i].replace(/\t/g, ' ');
|
||||
rows.push(` ${cur} ${num} ${mark} ${i === cursor ? chalk.white(text) : text}`);
|
||||
}
|
||||
out.push(rows.join('\n'));
|
||||
}
|
||||
if (notice) out.push(chalk.dim(` ${notice}`));
|
||||
out.push(chalk.dim(' ↑/↓ move · v mark · g/G top/bottom · Enter insert · Esc back'));
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ── Input view (unchanged) ──────────────────────────────────────────
|
||||
const lines = suggestions.map((c, i) => {
|
||||
const marker = i === active % count ? chalk.cyan('❯') : ' ';
|
||||
return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(NAME_WIDTH + 2))} ${chalk.dim(c.description)}`;
|
||||
});
|
||||
if (count > 1) {
|
||||
lines.push(chalk.dim(' ↑/↓ choose · Tab complete'));
|
||||
}
|
||||
|
||||
const header = [prefix, message, formattedValue].filter((v) => v !== undefined).join(' ');
|
||||
return [header, lines.length ? lines.join('\n') : undefined];
|
||||
});
|
||||
|
||||
export default autocompleteInput;
|
||||
464
src/fido.js
464
src/fido.js
@@ -1,15 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Fido — Minimal Coding Assistant
|
||||
* Fido \u2014 Minimal Coding Assistant
|
||||
*
|
||||
* A sandboxed, interactive coding assistant using LLMOrbiter.
|
||||
*
|
||||
* Entry point: wires configuration, tools, and the console REPL together.
|
||||
* Shared infrastructure lives in sibling modules (config, sandbox, dir-listing,
|
||||
* undo); each tool lives in its own file under src/tools/.
|
||||
* Entry point: wires configuration, tools, and the Ink REPL together. Shared
|
||||
* infrastructure lives in sibling modules (config, sandbox, dir-listing, undo);
|
||||
* each tool lives in its own file under src/tools/; the terminal UI lives under
|
||||
* src/ui/ (a single Ink app the command loop and the tools drive through a
|
||||
* small store).
|
||||
*
|
||||
* Config via .env (place in c:\src\fido\ or the working directory):
|
||||
* Config via .env (place in ~/.fido/ or the working directory):
|
||||
* OPENROUTER_API_KEY=sk-or-...
|
||||
* MODEL=xiaomi/mimo-v2.6-flash
|
||||
* UNDO_FILE=.fido-undo.json
|
||||
@@ -22,7 +24,6 @@
|
||||
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import { editor, input, select, confirm } from '@inquirer/prompts';
|
||||
import { marked } from 'marked';
|
||||
import { markedTerminal } from 'marked-terminal';
|
||||
|
||||
@@ -34,216 +35,144 @@ import { loadUndoLog, getUndoLog } from './undo.js';
|
||||
import { loadSuspends, addSuspend, getSuspends } from './suspend.js';
|
||||
import { tools, toolCallNotifications, toolResultNotifications } from './tools/index.js';
|
||||
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
|
||||
import { autocompleteInput } from './autocomplete-input.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';
|
||||
|
||||
// ─── Markdown → console renderer ───────────────────────────────────────────────
|
||||
// \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
|
||||
|
||||
// Configure marked to emit ANSI-styled terminal output. Width accounts for the
|
||||
// 2-space UI indent applied when printing the assistant's response.
|
||||
marked.use(markedTerminal({ reflowText: true, width: 76, tab: 2 }));
|
||||
marked.use(markedTerminal({ reflowText: true, width: getBlockContentWidth(), tab: 2 }));
|
||||
|
||||
// ─── Background-color blocks ───────────────────────────────────────────────────
|
||||
// \u2500\u2500\u2500 Background-color blocks \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
|
||||
//
|
||||
// marked-terminal (and chalk) end every styled span with a full reset
|
||||
// (\x1b[0m). A full reset also clears the active background, so naïvely wrapping
|
||||
// (\x1b[0m). A full reset also clears the active background, so na\u00efvely wrapping
|
||||
// a block in a bg color would drop the background right after the first styled
|
||||
// span. To keep the background alive across a block we:
|
||||
// 1. re-emit the background open code immediately after every reset, and
|
||||
// 2. right-pad each line to a fixed width so the block reads as a rectangle.
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
const RESET = '\x1b[0m';
|
||||
const BG_CLOSE = '\x1b[49m';
|
||||
// Transcript blocks use the shared role renderer in ./ui/blocks.js.
|
||||
|
||||
const ASSISTANT_BG_OPEN = '\x1b[48;2;30;41;59m'; // #1e293b — dark slate
|
||||
const SHELL_BG_OPEN = '\x1b[100m'; // dark gray (bgBlackBright)
|
||||
|
||||
const ASSISTANT_BLOCK_WIDTH = 76;
|
||||
const SHELL_BLOCK_WIDTH = 76;
|
||||
|
||||
const stripAnsi = (s) => s.replace(ANSI_RE, '');
|
||||
|
||||
/**
|
||||
* Paint every line of `text` on a solid background, right-padding each line to
|
||||
* `width` visible columns so the result reads as a block.
|
||||
*/
|
||||
function paintBackground(text, open, width) {
|
||||
return String(text)
|
||||
.replace(/\n+$/, '')
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const visible = stripAnsi(line).length;
|
||||
const padded = visible < width ? line + ' '.repeat(width - visible) : line;
|
||||
const patched = padded.replace(/\x1b\[0m/g, RESET + open);
|
||||
return open + patched + BG_CLOSE;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── System prompt builder ─────────────────────────────────────────────────────
|
||||
// \u2500\u2500\u2500 System prompt builder \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
|
||||
|
||||
function buildSystemPrompt() {
|
||||
const { entries, total, truncated } = listDir(ROOT_DIR, { seek: 0, limit: 50, recursive: false });
|
||||
const fmt = (e) => e.type === 'd' ? `d ${e.path}` : `f ${e.size ?? '?'} ${e.path}`;
|
||||
let dirSection = entries.map(fmt).join('\n');
|
||||
if (truncated > 0) {
|
||||
dirSection += `\n ... (${truncated} more entries not shown — use list_files to explore)`;
|
||||
dirSection += `\n ... (${truncated} more entries not shown \u2014 use list_files to explore)`;
|
||||
}
|
||||
|
||||
return `You're Fido, a coding assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.
|
||||
|
||||
## Workspace
|
||||
Root: ${ROOT_DIR}
|
||||
Total files: ${total}
|
||||
|
||||
## Workspace root (${total} entries):
|
||||
${dirSection}
|
||||
|
||||
## Tools & rules of thumb
|
||||
- \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing.
|
||||
- \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: list dir (non-recursive default). Honors .gitignore; dot-dirs (.git) hidden unless includeDotDirs=true. recursive=true walks subtree. seek/limit paginate. Paths rel to root.
|
||||
- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: regex-search file contents across ws (literal=true = plain str). Recurse dirs, honors .gitignore, skips binary/big files, hides dot-dirs. Returns matches {file,line,column,text}; line is 0-idx, feed straight to read_file seek. glob limits files searched. Long lines capped (textTruncated per match, linesTruncated count).
|
||||
- \`find_files(pattern, path, type, includeDotDirs, ignore_case, max_results)\`: find files/dirs by name glob across ws (like ripgrep, but for names). Recurses dirs, honors .gitignore, hides dot-dirs. A glob without "/" matches the basename (e.g. "*.js"); with "/" it matches the path. Returns entries {path,type,size}: type "f"/"d", size in bytes (null for dirs). type restricts to "f"/"d"; max_results caps the list.
|
||||
- \`patch(operations)\`: apply file edits/creates.
|
||||
- create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created).
|
||||
- search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` — replaces FIRST hit.
|
||||
- \`skiplines\` (opt int, 0-idx): skip N lines b4 matching; use if search text appears multiple times.
|
||||
- \`mkdir(dirname)\`: make dir (recursive).
|
||||
- \`rmdir(dirname)\`: delete dir + contents.
|
||||
- \`rm(filename)\`: delete file.
|
||||
- \`bash_exec(command, tail_lines, timeout)\`: run bash in ws root. No color/interactivity, stdin closed, killed at timeout. Needs explicit user approval — user may decline. stdout/stderr trimmed to last \`tail_lines\` lines (default 100).
|
||||
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). Use for current info, external docs, or facts not in ws. Untrusted 3rd-party text.
|
||||
- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: fetch full contents of web page(s) via Tavily extract. \`urls\` = URL or array. Optional \`query\` = prompt for only relevant bits of each page. Use to read a specific URL (external docs) beyond search snippets. Untrusted 3rd-party text.
|
||||
- \`ask_user(question, options)\`: ask user when intent ambiguous or you need a decision. Give a concise question + up to 3 labeled options (A,B,C); the user may pick one or type free-form (D).
|
||||
- \`ask_an_expert(question, context)\`: escalate to a human expert when you're genuinely stuck (a confusing or unclear problem, or you've tried and are still spinning). Don't use it for things you can resolve yourself. Question shown to user as copy-pasteable block for their expert; reply returned to you. Self-contained \`question\`; put supporting detail (errors, code, what you tried) in optional \`context\`.
|
||||
|
||||
## File mentions
|
||||
- User inserts refs via Ctrl+O: \`@path\` (whole file) or \`@path:start-end\` (line range).
|
||||
- Mentions are refs only — you must \`read_file\` yourself. Paths rel to root, forward slashes.
|
||||
- Mention line nums are 1-based; \`read_file\` is 0-idx → \`@path:start-end\` ⟹ \`read_file(path, seek=start-1)\`.
|
||||
|
||||
## Rules
|
||||
- Only access files under ws root. Prefer paths relative to the root; avoid ".." and absolute paths. (\`bash_exec\` starts in the ws root, so relative paths resolve there.)
|
||||
- Always \`read_file\` before patching (confirm exact search text + indentation).
|
||||
- Prefer targeted SEARCH/REPLACE over rewriting whole files.
|
||||
- Multiple \`patch\` ops in one call run sequentially.
|
||||
- Undo tracked per op, managed externally by user.
|
||||
`;
|
||||
return `You're Fido, a coding assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.\n\n## Workspace\nRoot: ${ROOT_DIR}\nTotal files: ${total}\n\n## Workspace root (${total} entries):\n${dirSection}\n\n## Tools & rules of thumb\n- \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing.\n- \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: list dir (non-recursive default). Honors .gitignore; dot-dirs (.git) hidden unless includeDotDirs=true. recursive=true walks subtree. seek/limit paginate. Paths rel to root.\n- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: regex-search file contents across ws (literal=true = plain str). Recurse dirs, honors .gitignore, skips binary/big files, hides dot-dirs. Returns matches {file,line,column,text}; line is 0-idx, feed straight to read_file seek. glob limits files searched. Long lines capped (textTruncated per match, linesTruncated count).\n- \`find_files(pattern, path, type, includeDotDirs, ignore_case, max_results)\`: find files/dirs by name glob across ws (like ripgrep, but for names). Recurses dirs, honors .gitignore, hides dot-dirs. A glob without "/" matches the basename (e.g. "*.js"); with "/" it matches the path. Returns entries {path,type,size}: type "f"/"d", size in bytes (null for dirs). type restricts to "f"/"d"; max_results caps the list.\n- \`patch(operations)\`: apply file edits/creates.\n - create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created).\n - search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` \u2014 replaces FIRST hit.\n - \`skiplines\` (opt int, 0-idx): skip N lines b4 matching; use if search text appears multiple times.\n- \`mkdir(dirname)\`: make dir (recursive).\n- \`rmdir(dirname)\`: delete dir + contents.\n- \`rm(filename)\`: delete file.\n- \`bash_exec(command, tail_lines, timeout)\`: run bash in ws root. No color/interactivity, stdin closed, killed at timeout. Needs explicit user approval \u2014 user may decline. stdout/stderr trimmed to last \`tail_lines\` lines (default 100).\n- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). Use for current info, external docs, or facts not in ws. Untrusted 3rd-party text.\n- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: fetch full contents of web page(s) via Tavily extract. \`urls\` = URL or array. Optional \`query\` = prompt for only relevant bits of each page. Use to read a specific URL (external docs) beyond search snippets. Untrusted 3rd-party text.\n- \`ask_user(question, options)\`: ask user when intent ambiguous or you need a decision. Give a concise question + up to 3 labeled options (A,B,C); the user may pick one or type free-form (D).\n- \`ask_an_expert(question, context)\`: escalate to a human expert when you're genuinely stuck (a confusing or unclear problem, or you've tried and are still spinning). Don't use it for things you can resolve yourself. Question shown to user as copy-pasteable block for their expert; reply returned to you. Self-contained \`question\`; put supporting detail (errors, code, what you tried) in optional \`context\`.\n\n## File mentions\n- User inserts refs via Ctrl+O: \`@path\` (whole file) or \`@path:start-end\` (line range).\n- Mentions are refs only \u2014 you must \`read_file\` yourself. Paths rel to root, forward slashes.\n- Mention line nums are 1-based; \`read_file\` is 0-idx \u2192 \`@path:start-end\` \u27f9 \`read_file(path, seek=start-1)\`.\n\n## Rules\n- Only access files under ws root. Prefer paths relative to the root; avoid ".." and absolute paths. (\`bash_exec\` starts in the ws root, so relative paths resolve there.)\n- Always \`read_file\` before patching (confirm exact search text + indentation).\n- Prefer targeted SEARCH/REPLACE over rewriting whole files.\n- Multiple \`patch\` ops in one call run sequentially.\n- Undo tracked per op, managed externally by user.\n`;
|
||||
}
|
||||
|
||||
// ─── Console UI ────────────────────────────────────────────────────────────────
|
||||
// \u2500\u2500\u2500 Transcript blocks \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
|
||||
//
|
||||
// Each builder returns a pre-rendered (mostly ANSI-styled) string that is
|
||||
// pushed to the transcript via `ui.log`. Nothing here writes to the terminal
|
||||
// directly \u2014 all output flows through the Ink app.
|
||||
|
||||
function printBanner() {
|
||||
console.log('');
|
||||
console.log(chalk.cyan.bold('╔══════════════════════════════════════════════╗'));
|
||||
console.log(chalk.cyan.bold('║') + chalk.white.bold(' 🐕 FIDO — Coding Assistant ') + chalk.cyan.bold('║'));
|
||||
console.log(chalk.cyan.bold('╚══════════════════════════════════════════════╝'));
|
||||
console.log(chalk.dim(` Workspace: ${ROOT_DIR}`));
|
||||
console.log(chalk.dim(` Model: ${MODEL}`));
|
||||
console.log(chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`));
|
||||
console.log(chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_DIR)}`));
|
||||
console.log('');
|
||||
function divider() {
|
||||
return chalk.dim(' ' + '\u2500'.repeat(50));
|
||||
}
|
||||
|
||||
function printDivider() {
|
||||
console.log(chalk.dim(' ' + '─'.repeat(50)));
|
||||
function banner() {
|
||||
return [
|
||||
'',
|
||||
chalk.cyan.bold('\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557'),
|
||||
chalk.cyan.bold('\u2551') + chalk.white.bold(' \ud83d\udc15 FIDO \u2014 Coding Assistant ') + chalk.cyan.bold('\u2551'),
|
||||
chalk.cyan.bold('\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d'),
|
||||
chalk.dim(` Workspace: ${ROOT_DIR}`),
|
||||
chalk.dim(` Model: ${MODEL}`),
|
||||
chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`),
|
||||
chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_DIR)}`),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function printAssistant(content) {
|
||||
console.log('');
|
||||
console.log(chalk.green.bold(' 🤖 Assistant'));
|
||||
printDivider();
|
||||
// Render markdown to ANSI-styled console output, then indent to match the UI
|
||||
function introLines() {
|
||||
return [
|
||||
chalk.dim(' Type your request in the command bar below. Type / for commands, /paste for multiline.'),
|
||||
chalk.dim(' Ctrl+O add a file mention. Ctrl+C exits at the prompt; while the model is running it cancels the turn.'),
|
||||
chalk.dim(' PgUp/PgDn scroll the history, End jumps back to live. Token and cost totals live in the footer box.'),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function goodbye() {
|
||||
return chalk.dim(' Goodbye! \ud83d\udc15');
|
||||
}
|
||||
|
||||
function assistantBlock(content) {
|
||||
let rendered;
|
||||
try {
|
||||
rendered = marked.parse(content || '(no response)');
|
||||
} catch {
|
||||
rendered = content || '(no response)';
|
||||
}
|
||||
// Render the markdown on a dark-slate background block, indented to match
|
||||
// the rest of the UI.
|
||||
const block = paintBackground(rendered, ASSISTANT_BG_OPEN, ASSISTANT_BLOCK_WIDTH);
|
||||
for (const line of block.split('\n')) {
|
||||
console.log(' ' + line);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
return [
|
||||
'',
|
||||
renderAssistantBlock([
|
||||
chalk.green.bold('\ud83e\udd16 Assistant'),
|
||||
chalk.dim('\u2500'.repeat(50)),
|
||||
rendered,
|
||||
].join('\n')),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function printStatus(msg) {
|
||||
process.stdout.write(chalk.yellow.dim(` ⚡ ${msg}\n`));
|
||||
function toolErrorLine(name, error) {
|
||||
return renderToolBlock(chalk.red(`\u2716 Tool error [${name}]: ${error}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function shellBlock(r) {
|
||||
const meta = [];
|
||||
if (r.exitCode !== undefined && r.exitCode !== null) meta.push(`exit ${r.exitCode}`);
|
||||
if (r.signal) meta.push(`signal ${r.signal}`);
|
||||
if (r.timedOut) meta.push('timed out');
|
||||
const lines = [chalk.dim(`\u21b3 bash_exec${meta.length ? ' (' + meta.join(', ') + ')' : ''}`)];
|
||||
|
||||
const body = [];
|
||||
if (r.stdout) body.push(r.stdout.replace(/\n+$/, ''));
|
||||
if (r.stderr) body.push(r.stderr.replace(/\n+$/, ''));
|
||||
const text = body.join('\n');
|
||||
if (text.trim()) lines.push(text);
|
||||
|
||||
if (r.truncationNote) lines.push(chalk.dim(`\u21b3 ${r.truncationNote}`));
|
||||
if (r.note) lines.push(chalk.dim(`\u21b3 ${r.note}`));
|
||||
return renderToolBlock(lines.join('\n'));
|
||||
}
|
||||
|
||||
function printToolResult(name, result) {
|
||||
// Shell output gets a dedicated render: full stdout/stderr on a gray block.
|
||||
// Shell output gets a dedicated render: full stdout/stderr in a tool block.
|
||||
if (name === 'bash_exec') {
|
||||
let r = result;
|
||||
if (typeof r === 'string') {
|
||||
try { r = JSON.parse(r); } catch { /* not JSON, fall through */ }
|
||||
}
|
||||
if (r && typeof r === 'object' && ('stdout' in r || 'stderr' in r)) {
|
||||
printShellResult(r);
|
||||
ui.log(shellBlock(r));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const str = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
const display = str.length > 200 ? str.slice(0, 197) + '...' : str;
|
||||
console.log(chalk.dim(` ↳ ${display}`));
|
||||
ui.log(renderToolBlock(chalk.dim(`\u21b3 ${display}`)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a bash_exec result: a one-line summary, then stdout/stderr on a
|
||||
* dark-gray background block (indented to match the rest of the UI).
|
||||
*/
|
||||
function printShellResult(r) {
|
||||
const meta = [];
|
||||
if (r.exitCode !== undefined && r.exitCode !== null) meta.push(`exit ${r.exitCode}`);
|
||||
if (r.signal) meta.push(`signal ${r.signal}`);
|
||||
if (r.timedOut) meta.push('timed out');
|
||||
console.log(chalk.dim(` ↳ bash_exec${meta.length ? ' (' + meta.join(', ') + ')' : ''}`));
|
||||
|
||||
const body = [];
|
||||
if (r.stdout) body.push(r.stdout.replace(/\n+$/, ''));
|
||||
if (r.stderr) body.push(r.stderr.replace(/\n+$/, ''));
|
||||
const text = body.join('\n').replace(/\n/g, '\n ');
|
||||
if (text.trim()) {
|
||||
const block = paintBackground(text, SHELL_BG_OPEN, SHELL_BLOCK_WIDTH);
|
||||
for (const line of block.split('\n')) {
|
||||
console.log(' ' + line);
|
||||
}
|
||||
}
|
||||
|
||||
if (r.truncationNote) console.log(chalk.dim(` ↳ ${r.truncationNote}`));
|
||||
if (r.note) console.log(chalk.dim(` ↳ ${r.note}`));
|
||||
}
|
||||
|
||||
function printToolError(name, error) {
|
||||
console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`));
|
||||
}
|
||||
|
||||
function printUsage(u, totals) {
|
||||
const parts = [`in=${u.input}`, `out=${u.output}`];
|
||||
if (u.cached) parts.push(`cached=${u.cached}`);
|
||||
if (u.cost) parts.push(`cost=$${u.cost.toFixed(5)}`);
|
||||
let line = ` 📊 ${parts.join(' ')}`;
|
||||
if (totals) {
|
||||
totals.calls += 1;
|
||||
totals.input += u.input || 0;
|
||||
totals.output += u.output || 0;
|
||||
totals.cached += u.cached || 0;
|
||||
totals.cost += u.cost || 0;
|
||||
const acc = [`in=${totals.input}`, `out=${totals.output}`];
|
||||
if (totals.cached) acc.push(`cached=${totals.cached}`);
|
||||
if (totals.cost) acc.push(`cost=$${totals.cost.toFixed(5)}`);
|
||||
line += ` [(${totals.calls}) ${acc.join(', ')}]`;
|
||||
}
|
||||
console.log(chalk.dim(line));
|
||||
function printUsage(u) {
|
||||
// Usage now lives in the fixed token/cost footer box (store totals) \u2014
|
||||
// no per-call history line, so the transcript is not spammed.
|
||||
ui.addUsage(u);
|
||||
}
|
||||
|
||||
function formatWhen(iso) {
|
||||
@@ -252,42 +181,24 @@ function formatWhen(iso) {
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
|
||||
}
|
||||
|
||||
function printCommandHelp() {
|
||||
function commandHelp() {
|
||||
const width = Math.max(...COMMANDS.map((c) => c.name.length));
|
||||
console.log('');
|
||||
console.log(chalk.green.bold(' 📖 Commands'));
|
||||
printDivider();
|
||||
const rows = [chalk.green.bold(' \ud83d\udcd6 Commands'), divider()];
|
||||
for (const c of COMMANDS) {
|
||||
console.log(' ' + chalk.cyan(c.name.padEnd(width)) + ' ' + chalk.dim(c.description));
|
||||
rows.push(' ' + chalk.cyan(c.name.padEnd(width)) + ' ' + chalk.dim(c.description));
|
||||
}
|
||||
console.log('');
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
// ─── Multiline input (editor prompt) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the user's $VISUAL/$EDITOR (falls back to notepad on Windows, vim
|
||||
* elsewhere) to compose or paste multiline content. Launches immediately —
|
||||
* no extra keypress — and returns the saved buffer as a string.
|
||||
*/
|
||||
async function promptMultiline(message = 'Compose or paste your text, then save & close the editor') {
|
||||
return editor({
|
||||
message: chalk.bold.cyan(message),
|
||||
waitForUserInput: false,
|
||||
postfix: '.md',
|
||||
theme: {
|
||||
prefix: chalk.cyan('›'),
|
||||
style: { message: chalk.bold.cyan },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Main loop ─────────────────────────────────────────────────────────────────
|
||||
// \u2500\u2500\u2500 Main loop \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
|
||||
async function main() {
|
||||
loadUndoLog();
|
||||
loadSuspends();
|
||||
printBanner();
|
||||
|
||||
mount();
|
||||
ui.setMeta({ model: MODEL, workspace: ROOT_DIR });
|
||||
ui.log(banner());
|
||||
|
||||
// Reasoning effort for this session; null means "let the model default apply".
|
||||
// When set, it is sent as body.reasoning.effort on every request.
|
||||
@@ -301,13 +212,17 @@ async function main() {
|
||||
|
||||
const provider = new OpenRouterProvider({ apiKey: API_KEY });
|
||||
|
||||
// Running totals across the session, echoed after each per-call usage line.
|
||||
const usageTotals = { calls: 0, input: 0, output: 0, cached: 0, cost: 0 };
|
||||
|
||||
const orbiter = new LLMOrbiter(provider, {
|
||||
onStatus: (s) => printStatus(s),
|
||||
onReasoning: (text) => { turnReasoning = text; },
|
||||
onStatus: (s) => ui.status(s),
|
||||
onReasoning: (text) => {
|
||||
turnReasoning = text;
|
||||
ui.setReasoning(text);
|
||||
},
|
||||
onContent: () => {
|
||||
ui.finalizeReasoning();
|
||||
},
|
||||
onToolCall: (c) => {
|
||||
ui.finalizeReasoning();
|
||||
const notify = toolCallNotifications[c.name];
|
||||
if (notify) notify(c.args);
|
||||
else printToolCall(c.name, c.args);
|
||||
@@ -318,16 +233,19 @@ async function main() {
|
||||
else emitGenericToolResult(r.name, r.result);
|
||||
printToolResult(r.name, r.result);
|
||||
},
|
||||
onToolError: (e) => printToolError(e.name, e.error),
|
||||
onUsage: (u) => printUsage(u, usageTotals),
|
||||
onToolError: (e) => ui.log(toolErrorLine(e.name, e.error)),
|
||||
onUsage: (u) => printUsage(u),
|
||||
});
|
||||
|
||||
// While a turn is running there is no prompt, so Ctrl+C (delivered by Ink as
|
||||
// a keypress) is routed here to abort the turn.
|
||||
ui.onCancel(() => orbiter.abort('cancelled by user'));
|
||||
|
||||
// Conversation state: persisted across turns
|
||||
const systemPrompt = buildSystemPrompt();
|
||||
let messages = [{ role: 'system', content: systemPrompt }];
|
||||
|
||||
console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline.'));
|
||||
console.log(chalk.dim(' Ctrl+O add a file mention. Ctrl+C exits at the prompt; while the model is running it cancels the turn.\n'));
|
||||
ui.log(introLines());
|
||||
|
||||
// Restored into the prompt after a Ctrl+C cancels a running turn, so the
|
||||
// message can be edited and resent instead of retyped.
|
||||
@@ -336,24 +254,16 @@ async function main() {
|
||||
while (true) {
|
||||
let userInput;
|
||||
try {
|
||||
userInput = await autocompleteInput({
|
||||
message: chalk.bold.cyan('You'),
|
||||
default: draft,
|
||||
theme: {
|
||||
prefix: chalk.cyan('›'),
|
||||
style: {
|
||||
answer: chalk.white,
|
||||
message: chalk.bold.cyan,
|
||||
},
|
||||
},
|
||||
});
|
||||
userInput = await ui.ask({ message: 'You', prefill: draft });
|
||||
} catch {
|
||||
// Ctrl+C / EOF
|
||||
console.log(chalk.dim('\n Goodbye! 🐕\n'));
|
||||
break;
|
||||
userInput = null;
|
||||
}
|
||||
draft = '';
|
||||
|
||||
if (userInput === null) {
|
||||
ui.log(goodbye());
|
||||
break;
|
||||
}
|
||||
if (!userInput.trim()) continue;
|
||||
|
||||
// Handle special commands
|
||||
@@ -361,42 +271,43 @@ async function main() {
|
||||
|
||||
if (userInput.trim().startsWith('/') && command === null) {
|
||||
const name = userInput.trim().split(/\s+/)[0];
|
||||
console.log(chalk.yellow(`\n Unknown command: ${name}`));
|
||||
printCommandHelp();
|
||||
ui.log(chalk.yellow(` Unknown command: ${name}`));
|
||||
ui.log(commandHelp());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (command === '/quit') {
|
||||
console.log(chalk.dim('\n Goodbye! 🐕\n'));
|
||||
ui.log(goodbye());
|
||||
break;
|
||||
}
|
||||
|
||||
if (command === '/paste') {
|
||||
try {
|
||||
const pasted = await promptMultiline();
|
||||
const pasted = await ui.composeInEditor('');
|
||||
if (!pasted || !pasted.trim()) {
|
||||
console.log(chalk.yellow('\n Nothing pasted.\n'));
|
||||
ui.log(chalk.yellow(' Nothing pasted.'));
|
||||
continue;
|
||||
}
|
||||
userInput = pasted;
|
||||
printStatus(`Pasted ${pasted.split('\n').length} line(s), sending…`);
|
||||
ui.log(chalk.dim(` \u26a1 Pasted ${pasted.split('\n').length} line(s), sending\u2026`));
|
||||
} catch {
|
||||
console.log(chalk.yellow('\n Paste cancelled.\n'));
|
||||
ui.log(chalk.yellow(' Paste cancelled.'));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (command === '/undo') {
|
||||
const undoLog = getUndoLog();
|
||||
if (undoLog.length === 0) {
|
||||
console.log(chalk.yellow('\n Nothing to undo.\n'));
|
||||
ui.log(chalk.yellow(' Nothing to undo.'));
|
||||
} else {
|
||||
console.log(chalk.yellow('\n Undo log (newest first):'));
|
||||
const rows = [chalk.yellow(' Undo log (newest first):')];
|
||||
for (const e of undoLog.slice(0, 10)) {
|
||||
console.log(chalk.dim(` ${e.op} ${e.path}`));
|
||||
rows.push(chalk.dim(` ${e.op} ${e.path}`));
|
||||
}
|
||||
if (undoLog.length > 10) {
|
||||
console.log(chalk.dim(` ... and ${undoLog.length - 10} more in ${UNDO_FILE}`));
|
||||
rows.push(chalk.dim(` ... and ${undoLog.length - 10} more in ${UNDO_FILE}`));
|
||||
}
|
||||
console.log('');
|
||||
ui.log(rows.join('\n'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -404,17 +315,18 @@ async function main() {
|
||||
if (command === '/reasoning') {
|
||||
const arg = userInput.trim().slice('/reasoning'.length).trim().toLowerCase();
|
||||
if (!arg) {
|
||||
console.log(chalk.dim(`\n Reasoning effort: ${reasoningEffort || 'default (model)'}`));
|
||||
console.log(chalk.dim(` Usage: /reasoning ${REASONING_LEVELS.join('|')}\n`));
|
||||
ui.log(chalk.dim(` Reasoning effort: ${reasoningEffort || 'default (model)'}`));
|
||||
ui.log(chalk.dim(` Usage: /reasoning ${REASONING_LEVELS.join('|')}`));
|
||||
continue;
|
||||
}
|
||||
if (!REASONING_LEVELS.includes(arg)) {
|
||||
console.log(chalk.yellow(`\n Unknown reasoning level: ${arg}`));
|
||||
console.log(chalk.dim(` Choose one of: ${REASONING_LEVELS.join(' | ')}\n`));
|
||||
ui.log(chalk.yellow(` Unknown reasoning level: ${arg}`));
|
||||
ui.log(chalk.dim(` Choose one of: ${REASONING_LEVELS.join(' | ')}`));
|
||||
continue;
|
||||
}
|
||||
reasoningEffort = arg;
|
||||
console.log(chalk.green(`\n 🧠 Reasoning effort set to ${arg}\n`));
|
||||
ui.setMeta({ reasoning: arg });
|
||||
ui.log(chalk.green(` \ud83e\udde0 Reasoning effort set to ${arg}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -424,56 +336,46 @@ async function main() {
|
||||
if (!description) {
|
||||
try {
|
||||
description = (
|
||||
await input({
|
||||
message: chalk.bold.cyan('Short description for this suspend'),
|
||||
theme: {
|
||||
prefix: chalk.cyan('›'),
|
||||
style: { answer: chalk.white, message: chalk.bold.cyan },
|
||||
},
|
||||
})
|
||||
await ui.input({ message: 'Short description for this suspend', color: 'cyan' })
|
||||
).trim();
|
||||
} catch {
|
||||
console.log(chalk.yellow('\n Suspend cancelled.\n'));
|
||||
ui.log(chalk.yellow(' Suspend cancelled.'));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!description) {
|
||||
console.log(chalk.yellow('\n No description given — suspend cancelled.\n'));
|
||||
ui.log(chalk.yellow(' No description given \u2014 suspend cancelled.'));
|
||||
continue;
|
||||
}
|
||||
if (messages.length <= 1) {
|
||||
console.log(chalk.yellow('\n Nothing to suspend yet — say something first.\n'));
|
||||
ui.log(chalk.yellow(' Nothing to suspend yet \u2014 say something first.'));
|
||||
continue;
|
||||
}
|
||||
addSuspend(description, messages);
|
||||
const count = getSuspends().length;
|
||||
console.log(chalk.green(`\n ⏸ Suspended: ${description}`));
|
||||
console.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_DIR}\n`));
|
||||
ui.log(chalk.green(` \u23f8 Suspended: ${description}`));
|
||||
ui.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_DIR}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (command === '/recall') {
|
||||
const suspends = getSuspends();
|
||||
if (suspends.length === 0) {
|
||||
console.log(chalk.yellow('\n Nothing to recall.\n'));
|
||||
ui.log(chalk.yellow(' Nothing to recall.'));
|
||||
continue;
|
||||
}
|
||||
let choice;
|
||||
try {
|
||||
choice = await select({
|
||||
message: chalk.bold.cyan('Recall a suspended conversation'),
|
||||
choice = await ui.select({
|
||||
message: 'Recall a suspended conversation',
|
||||
color: 'cyan',
|
||||
choices: suspends.map((s, i) => ({
|
||||
name: `${s.description} ${chalk.dim(formatWhen(s.createdAt))}`,
|
||||
value: i,
|
||||
short: s.description,
|
||||
})),
|
||||
theme: {
|
||||
prefix: chalk.cyan('›'),
|
||||
style: { message: chalk.bold.cyan, highlight: chalk.cyan.bold },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
console.log(chalk.yellow('\n Recall cancelled.\n'));
|
||||
ui.log(chalk.yellow(' Recall cancelled.'));
|
||||
continue;
|
||||
}
|
||||
const snap = suspends[choice];
|
||||
@@ -485,19 +387,18 @@ async function main() {
|
||||
...restored.filter((m) => m.role !== 'system'),
|
||||
];
|
||||
const turns = messages.length - 1;
|
||||
console.log(chalk.green(`\n ▶ Recalled: ${snap.description}`));
|
||||
console.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored.\n`));
|
||||
ui.log(chalk.green(` \u25b6 Recalled: ${snap.description}`));
|
||||
ui.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored.`));
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// No prompt owns the terminal while the model works, so a Ctrl+C would
|
||||
// otherwise hit Node's default handler and kill the process. Catch it
|
||||
// here instead: abort the active run and fall back through to the prompt
|
||||
// (prefilled with the message) rather than exiting.
|
||||
// Fresh reasoning buffer for this turn.
|
||||
// No prompt owns the terminal while the model works, so Ctrl+C is
|
||||
// handled via the UI (Ink keypress) and, as a fallback when raw mode is
|
||||
// unavailable, via a SIGINT listener for the duration of the turn.
|
||||
turnReasoning = '';
|
||||
ui.clearReasoning();
|
||||
ui.busy(true);
|
||||
ui.status('thinking\u2026');
|
||||
|
||||
const onSigint = () => orbiter.abort('cancelled by user');
|
||||
process.on('SIGINT', onSigint);
|
||||
@@ -523,20 +424,22 @@ async function main() {
|
||||
// not re-sent every turn (nor mistaken for the current file).
|
||||
const invalidatedReads = invalidateStaleReadResults(messages);
|
||||
if (COMPACT_DEBUG) {
|
||||
console.log(chalk.dim(
|
||||
` [compact] patch tool calls compacted: ${compactedPatches} · ` +
|
||||
ui.log(chalk.dim(
|
||||
` [compact] patch tool calls compacted: ${compactedPatches} \u00b7 ` +
|
||||
`stale read results invalidated: ${invalidatedReads}`
|
||||
));
|
||||
}
|
||||
|
||||
printAssistant(result.content);
|
||||
ui.finalizeReasoning();
|
||||
ui.log(assistantBlock(result.content));
|
||||
} catch (err) {
|
||||
ui.clearReasoning();
|
||||
if (err && err.name === 'AbortError') {
|
||||
// Ctrl+C cancelled the run — put the message back in the prompt
|
||||
// Ctrl+C cancelled the run \u2014 put the message back in the prompt
|
||||
// so it can be edited and resent. The aborted transcript is
|
||||
// discarded, so `messages` is untouched and the turn is not lost.
|
||||
draft = userInput;
|
||||
console.log(chalk.dim('\n ⏹ Cancelled — your message is back in the prompt; edit it and press Enter to resend.\n'));
|
||||
ui.log(chalk.dim(' \u23f9 Cancelled \u2014 your message is back in the prompt; edit it and press Enter to resend.'));
|
||||
|
||||
// If the model already produced reasoning before the cancel,
|
||||
// offer to keep it. Kept reasoning is added to the transcript
|
||||
@@ -547,35 +450,46 @@ async function main() {
|
||||
if (reasoning) {
|
||||
let keep = false;
|
||||
try {
|
||||
keep = await confirm({
|
||||
message: chalk.bold.cyan('Keep the reasoning the model produced before cancelling?'),
|
||||
keep = await ui.confirm({
|
||||
message: 'Keep the reasoning the model produced before cancelling?',
|
||||
default: true,
|
||||
theme: {
|
||||
prefix: chalk.cyan('›'),
|
||||
style: { message: chalk.bold.cyan },
|
||||
},
|
||||
color: 'cyan',
|
||||
});
|
||||
} catch {
|
||||
keep = false; // prompt dismissed (e.g. Ctrl+C) — don't keep
|
||||
keep = false; // prompt dismissed (e.g. Ctrl+C) \u2014 don't keep
|
||||
}
|
||||
if (keep) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: `${reasoning}\n---USER-CANCELLED---`,
|
||||
});
|
||||
console.log(chalk.dim(' ↗ Reasoning kept in the transcript.\n'));
|
||||
ui.finalizeReasoning(reasoning);
|
||||
ui.log(chalk.dim(' \u2197 Reasoning kept in the transcript.'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.red(`\n ✖ Error: ${err.message}\n`));
|
||||
ui.log(chalk.red(` \u2716 Error: ${err.message}`));
|
||||
}
|
||||
} finally {
|
||||
process.removeListener('SIGINT', onSigint);
|
||||
}
|
||||
ui.busy(false);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
await unmount();
|
||||
}
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error(chalk.red.bold('\n\u2716 Missing OPENROUTER_API_KEY \u2014 add it to .env\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
try {
|
||||
await unmount();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
console.error(chalk.red.bold('\nFATAL: ' + err.message));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* File-browser helpers for prompt file mentions.
|
||||
*
|
||||
* Pure, terminal-free helpers used by the in-prompt file browser in
|
||||
* `autocomplete-input.js`. Keeping them here means the listing, mention
|
||||
* `src/ui/main-input.js`. Keeping them here means the listing, mention
|
||||
* formatting and range math can be unit-tested without a TTY.
|
||||
*
|
||||
* Everything path-related stays relative to `ROOT_DIR` and uses forward
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { input } from '@inquirer/prompts';
|
||||
import { defineTool } from 'llmorbiter';
|
||||
import { ui } from '../ui/index.js';
|
||||
import { renderToolBlock } from '../ui/blocks.js';
|
||||
import { emitToolCall, clip } from './notify.js';
|
||||
|
||||
export const askExpertTool = defineTool({
|
||||
@@ -39,26 +40,22 @@ export const askExpertTool = defineTool({
|
||||
}
|
||||
const message = lines.join('\n');
|
||||
|
||||
console.log('');
|
||||
console.log(chalk.blue.bold(' 🧑\u200d🔬 Ask an expert — copy & paste this to your expert:'));
|
||||
console.log(chalk.dim(' ┌' + '─'.repeat(60)));
|
||||
for (const line of message.split('\n')) {
|
||||
console.log(chalk.white(' │ ' + line));
|
||||
}
|
||||
console.log(chalk.dim(' └' + '─'.repeat(60)));
|
||||
console.log('');
|
||||
const panel = renderToolBlock([
|
||||
chalk.blue.bold('\ud83e\uddd1\u200d\ud83d\udd2c Ask an expert \u2014 copy & paste this to your expert:'),
|
||||
chalk.dim('\u2500'.repeat(60)),
|
||||
...message.split('\n').map((line) => chalk.white(line)),
|
||||
].join('\n'));
|
||||
ui.log('');
|
||||
ui.log(panel);
|
||||
ui.log('');
|
||||
|
||||
// Wait for the user to bring back the expert's answer.
|
||||
const answer = await input({
|
||||
message: chalk.bold.blue("Expert's answer (paste it here)"),
|
||||
prefix: chalk.blue('›'),
|
||||
theme: {
|
||||
prefix: chalk.blue('›'),
|
||||
style: { answer: chalk.white, message: chalk.bold.blue },
|
||||
},
|
||||
const answer = await ui.input({
|
||||
message: "Expert's answer (paste it here)",
|
||||
color: 'blue',
|
||||
});
|
||||
|
||||
console.log('');
|
||||
ui.log('');
|
||||
return { answer };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { defineTool } from 'llmorbiter';
|
||||
import { ui } from '../ui/index.js';
|
||||
import { renderToolBlock } from '../ui/blocks.js';
|
||||
import { emitToolCall, clip } from './notify.js';
|
||||
|
||||
export const askUserTool = defineTool({
|
||||
@@ -31,21 +32,19 @@ export const askUserTool = defineTool({
|
||||
const capped = options.slice(0, 3);
|
||||
const letters = ['A', 'B', 'C'];
|
||||
|
||||
console.log('');
|
||||
console.log(chalk.magenta.bold(' ❓ Fido asks:'));
|
||||
console.log(chalk.white(' ' + question));
|
||||
ui.log('');
|
||||
ui.log(renderToolBlock([
|
||||
chalk.magenta.bold('\u2753 Fido asks:'),
|
||||
chalk.white(question),
|
||||
].join('\n')));
|
||||
|
||||
if (capped.length === 0) {
|
||||
// No options — just free text
|
||||
const answer = await input({
|
||||
message: chalk.bold.magenta('Your answer'),
|
||||
prefix: chalk.magenta('›'),
|
||||
theme: {
|
||||
prefix: chalk.magenta('›'),
|
||||
style: { answer: chalk.white, message: chalk.bold.magenta },
|
||||
},
|
||||
// No options \u2014 just free text
|
||||
const answer = await ui.input({
|
||||
message: 'Your answer',
|
||||
color: 'magenta',
|
||||
});
|
||||
console.log('');
|
||||
ui.log('');
|
||||
return { answer };
|
||||
}
|
||||
|
||||
@@ -54,37 +53,28 @@ export const askUserTool = defineTool({
|
||||
...capped.map((opt, i) => ({
|
||||
name: `${letters[i]}) ${opt}`,
|
||||
value: letters[i],
|
||||
short: letters[i],
|
||||
})),
|
||||
{ name: 'D) Type my own answer…', value: '__custom__', short: 'D' },
|
||||
{ name: 'D) Type my own answer\u2026', value: '__custom__' },
|
||||
];
|
||||
|
||||
const selection = await select({
|
||||
const selection = await ui.select({
|
||||
message: question,
|
||||
choices,
|
||||
prefix: chalk.magenta('›'),
|
||||
theme: {
|
||||
prefix: chalk.magenta('›'),
|
||||
style: { message: chalk.bold.magenta },
|
||||
},
|
||||
color: 'magenta',
|
||||
});
|
||||
|
||||
let answer;
|
||||
if (selection === '__custom__') {
|
||||
answer = await input({
|
||||
message: chalk.bold.magenta('Your answer'),
|
||||
prefix: chalk.magenta('›'),
|
||||
theme: {
|
||||
prefix: chalk.magenta('›'),
|
||||
style: { answer: chalk.white, message: chalk.bold.magenta },
|
||||
},
|
||||
answer = await ui.input({
|
||||
message: 'Your answer',
|
||||
color: 'magenta',
|
||||
});
|
||||
} else {
|
||||
const idx = letters.indexOf(selection);
|
||||
answer = capped[idx];
|
||||
}
|
||||
|
||||
console.log('');
|
||||
ui.log('');
|
||||
return { answer };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,16 +13,17 @@
|
||||
* on a terminal prompt.
|
||||
* - Every command requires interactive approval via a select menu (run once,
|
||||
* allow for the rest of this session, reject, or reject with a comment),
|
||||
* using the same inquirer/chalk styling as the ask_user tool.
|
||||
* using the same Ink/chalk styling as the ask_user tool.
|
||||
* - A hard timeout kills the whole process group; output is trimmed to the
|
||||
* last `tail_lines` lines before being returned to the model.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import chalk from 'chalk';
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { defineTool, ToolError } from 'llmorbiter';
|
||||
import { ROOT_DIR } from '../config.js';
|
||||
import { ui } from '../ui/index.js';
|
||||
import { renderToolBlock } from '../ui/blocks.js';
|
||||
import { emitToolCall, emitToolResult, clip } from './notify.js';
|
||||
|
||||
const DEFAULT_TAIL_LINES = 100; // trailing lines of output returned to the model
|
||||
@@ -97,43 +98,41 @@ async function requestApproval(command, timeoutMs) {
|
||||
);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(chalk.yellow.bold(' ⚠ Fido wants to run a shell command'));
|
||||
console.log(chalk.dim(` cwd: ${ROOT_DIR}`));
|
||||
console.log(chalk.dim(` timeout: ${timeoutMs} ms`));
|
||||
console.log(chalk.white(' ' + command.replace(/\r?\n/g, '\n ')));
|
||||
console.log('');
|
||||
ui.log('');
|
||||
ui.log(renderToolBlock([
|
||||
chalk.yellow.bold('⚠ Fido wants to run a shell command'),
|
||||
chalk.dim(`cwd: ${ROOT_DIR}`),
|
||||
chalk.dim(`timeout: ${timeoutMs} ms`),
|
||||
chalk.white(command.replace(/\r?\n/g, '\n')),
|
||||
].join('\n')));
|
||||
ui.log('');
|
||||
|
||||
// A prior "yes for this session" skips the prompt entirely.
|
||||
if (sessionApproved) {
|
||||
console.log(chalk.dim(' ⚡ Auto-approved (allowed for this session).'));
|
||||
console.log('');
|
||||
ui.log(renderToolBlock(chalk.dim('⚡ Auto-approved (allowed for this session).')));
|
||||
ui.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
let choice;
|
||||
try {
|
||||
choice = await select({
|
||||
message: chalk.bold.yellow('Approve running this command?'),
|
||||
choice = await ui.select({
|
||||
message: 'Approve running this command?',
|
||||
color: 'yellow',
|
||||
default: 'Y',
|
||||
choices: [
|
||||
{ name: 'Yes — run it once', value: 'Y' },
|
||||
{ name: 'Yes — allow for the rest of this session', value: 'a' },
|
||||
{ name: 'No — skip this command', value: 'n' },
|
||||
{ name: 'No — skip and leave a comment', value: 'c' },
|
||||
],
|
||||
default: 'Y',
|
||||
prefix: chalk.yellow('›'),
|
||||
theme: {
|
||||
prefix: chalk.yellow('›'),
|
||||
style: { message: chalk.bold.yellow, highlight: chalk.yellow.bold },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Ctrl+C / EOF during the prompt → treat as a rejection.
|
||||
choice = 'n';
|
||||
}
|
||||
|
||||
console.log('');
|
||||
ui.log('');
|
||||
|
||||
if (choice === 'Y') {
|
||||
return;
|
||||
@@ -141,8 +140,8 @@ async function requestApproval(command, timeoutMs) {
|
||||
|
||||
if (choice === 'a') {
|
||||
sessionApproved = true;
|
||||
console.log(chalk.dim(' ✓ Allowed — shell commands will run without prompting for the rest of this session.'));
|
||||
console.log('');
|
||||
ui.log(renderToolBlock(chalk.dim('✓ Allowed — shell commands will run without prompting for the rest of this session.')));
|
||||
ui.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,19 +149,16 @@ async function requestApproval(command, timeoutMs) {
|
||||
let comment = '';
|
||||
try {
|
||||
comment = (
|
||||
await input({
|
||||
message: chalk.bold.yellow('Reason (optional comment)'),
|
||||
theme: {
|
||||
prefix: chalk.yellow('›'),
|
||||
style: { answer: chalk.white, message: chalk.bold.yellow },
|
||||
},
|
||||
await ui.input({
|
||||
message: 'Reason (optional comment)',
|
||||
color: 'yellow',
|
||||
})
|
||||
).trim();
|
||||
} catch {
|
||||
// Ctrl+C / EOF during the comment prompt → reject with no comment.
|
||||
comment = '';
|
||||
}
|
||||
console.log('');
|
||||
ui.log('');
|
||||
throw new ToolError(
|
||||
comment
|
||||
? `Command rejected by the user. Comment: ${comment}`
|
||||
|
||||
@@ -7,9 +7,14 @@
|
||||
* tailored notification when the model invokes that tool. These helpers keep
|
||||
* the styling consistent across tools and provide the generic fallback used
|
||||
* for any tool that does not supply its own renderer.
|
||||
*
|
||||
* Notifications are emitted through the UI store (Ink), not `console`, so they
|
||||
* appear in the transcript above the live prompt.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { ui } from '../ui/index.js';
|
||||
import { renderToolBlock } from '../ui/blocks.js';
|
||||
|
||||
// Longest argument string echoed in a notification before it is elided.
|
||||
const MAX_ARG_LEN = 120;
|
||||
@@ -25,7 +30,7 @@ export function clip(text, max = MAX_ARG_LEN) {
|
||||
* "\u2699 Tool: <name>(<detail>)"
|
||||
*/
|
||||
export function emitToolCall(name, detail = '') {
|
||||
console.log(chalk.cyan(` \u2699 Tool: ${chalk.bold(name)}(${detail})`));
|
||||
ui.log(renderToolBlock(chalk.cyan(`\u2699 Tool: ${chalk.bold(name)}(${detail})`)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +50,7 @@ export function printToolCall(name, args) {
|
||||
*/
|
||||
export function emitToolResult(name, detail = '') {
|
||||
const suffix = detail ? ' ' + detail : '';
|
||||
console.log(chalk.green(` \u2713 ${chalk.bold(name)}${suffix}`));
|
||||
ui.log(renderToolBlock(chalk.green(`\u2713 ${chalk.bold(name)}${suffix}`)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
296
src/ui/app.js
Normal file
296
src/ui/app.js
Normal file
@@ -0,0 +1,296 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The root Ink component — fullscreen fixed-pane TUI.
|
||||
*
|
||||
* Layout (locked to the terminal size, alternate screen):
|
||||
* header — 1-line chrome: model / workspace / reasoning / scroll state.
|
||||
* history — flex-grow pane, bottom-anchored, top-clipped (Yoga
|
||||
* `overflow: hidden` + `justifyContent: flex-end`). Entry-
|
||||
* level windowing: only the last N entries are mounted and
|
||||
* `store.scroll` slices upward from the live tail.
|
||||
* command bar — bordered box, fixed at the bottom: the active prompt, or a
|
||||
* placeholder while the model works.
|
||||
* footer — token/cost totals box + key hints.
|
||||
*
|
||||
* History windowing is line-granular: an entry is one logged block (banner,
|
||||
* reply, tool notice...), but scrolling operates on the rendered lines inside
|
||||
* those blocks. PgUp/PgDn and mouse-wheel events therefore move without
|
||||
* skipping a complete multi-line block. Any new log line keeps the current
|
||||
* offset (clamped) so reading history isn't yanked to the tail.
|
||||
*
|
||||
* State lives in the external store (see ./store.js); we subscribe with
|
||||
* `useSyncExternalStore` so the plain async command loop and the tools can
|
||||
* drive the view without being React components.
|
||||
*
|
||||
* Written without JSX (the project runs straight from source).
|
||||
*/
|
||||
|
||||
import {
|
||||
createElement as h,
|
||||
useEffect,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { Box, Text, useApp, useInput, useWindowSize } from 'ink';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { MainInput } from './main-input.js';
|
||||
import { TextPrompt, SelectPrompt, ConfirmPrompt } from './prompts.js';
|
||||
import { wheelDeltaFromInput } from './mouse.js';
|
||||
import { renderReasoningBlock } from './blocks.js';
|
||||
|
||||
const SPINNER = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
|
||||
|
||||
// Max history entries mounted per frame. Fullscreen re-renders history every
|
||||
// frame (unlike <Static>'s write-once), so cap reconciler work. The store
|
||||
// keeps up to 1000; only the tail near the viewport is mounted.
|
||||
const MAX_VISIBLE_LINES = 120;
|
||||
const SCROLL_STEP = 10;
|
||||
const WHEEL_STEP = 3;
|
||||
|
||||
// SGR (1006) mouse-reporting sequences. 1000 = click + wheel reports only
|
||||
// (no hover/drag flood); 1006 = SGR extended format (\x1b[<Cb;X;YM).
|
||||
// Wheel up = Cb 64 (or 96 with Shift), wheel down = 65 (or 97 with Shift).
|
||||
const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1006h';
|
||||
const MOUSE_DISABLE = '\x1b[?1000l\x1b[?1006l';
|
||||
|
||||
function setMouseTracking(on) {
|
||||
if (!process.stdout?.isTTY) return;
|
||||
if (process.env.FIDO_NO_MOUSE === '1') return;
|
||||
try {
|
||||
process.stdout.write(on ? MOUSE_ENABLE : MOUSE_DISABLE);
|
||||
} catch {
|
||||
/* non-interactive / closed stdout; ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const fmtInt = (n) => Number(n || 0).toLocaleString('en-US');
|
||||
const fmtCost = (c) => '$' + Number(c || 0).toFixed(5);
|
||||
|
||||
/** Format streaming or finalized reasoning as transcript entry lines. */
|
||||
export function formatReasoning(text, isLive = false, options = {}) {
|
||||
if (!text || !String(text).trim()) return [];
|
||||
const header = chalk.magenta.bold('\ud83e\udde0 Reasoning') + (isLive ? chalk.dim(' (thinking\u2026)') : '');
|
||||
const div = chalk.dim('\u2500'.repeat(50));
|
||||
const lines = String(text).replace(/\n+$/, '').split('\n');
|
||||
const body = lines.map((l) => chalk.dim(l));
|
||||
const rendered = renderReasoningBlock([header, div, ...body].join('\n'), options);
|
||||
return ['', ...rendered.split('\n'), ''];
|
||||
}
|
||||
|
||||
/** Truncate a plain (no-ANSI) string to `width` visible columns. */
|
||||
function truncatePlain(s, width) {
|
||||
const str = String(s || '');
|
||||
if (width <= 0) return '';
|
||||
if (str.length <= width) return str;
|
||||
return str.slice(0, Math.max(0, width - 1)) + '\u2026';
|
||||
}
|
||||
|
||||
function StatusLine({ status }) {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setTick((n) => n + 1), 120);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
const frame = SPINNER[tick % SPINNER.length];
|
||||
return h(Text, { color: 'yellow' }, ` ${frame} ${status || 'working\u2026'}`);
|
||||
}
|
||||
|
||||
function PromptView({ prompt, store }) {
|
||||
switch (prompt.type) {
|
||||
case 'main':
|
||||
return h(MainInput, { prompt, store });
|
||||
case 'input':
|
||||
return h(TextPrompt, { prompt, store });
|
||||
case 'select':
|
||||
return h(SelectPrompt, { prompt, store });
|
||||
case 'confirm':
|
||||
return h(ConfirmPrompt, { prompt, store });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function Header({ meta, scroll, columns }) {
|
||||
const bits = ['\ud83d\udc15 FIDO'];
|
||||
if (meta.model) bits.push(meta.model);
|
||||
if (meta.workspace) bits.push(meta.workspace);
|
||||
if (meta.reasoning) bits.push('reasoning:' + meta.reasoning);
|
||||
if (scroll > 0) bits.push(`\u25b2 ${scroll} lines (PgUp/PgDn \u00b7 wheel \u00b7 End=live)`);
|
||||
// 4 columns of chrome: 2 borders + 2 padding.
|
||||
const line = truncatePlain(bits.join(' \u00b7 '), Math.max(0, columns - 4));
|
||||
return h(
|
||||
Box,
|
||||
{ borderStyle: 'round', borderColor: 'cyan', flexShrink: 0 },
|
||||
h(Text, { bold: true, wrap: 'truncate' }, ' ' + line + ' '),
|
||||
);
|
||||
}
|
||||
|
||||
/** Count the terminal lines represented by a transcript. */
|
||||
export function countHistoryLines(entries) {
|
||||
if (!Array.isArray(entries)) return 0;
|
||||
return entries.reduce((count, entry) => {
|
||||
if (!entry || entry.text == null || entry.text === '') return count;
|
||||
return count + String(entry.text).split('\n').length;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a bounded window ending at the live transcript tail.
|
||||
*
|
||||
* Transcript entries are logical blocks, but their rendered height can span
|
||||
* many lines. Flattening them here means `scroll=1` removes one rendered line,
|
||||
* not one complete block.
|
||||
*/
|
||||
export function getHistoryWindow(entries, scroll = 0, maxLines = MAX_VISIBLE_LINES) {
|
||||
const lines = [];
|
||||
if (Array.isArray(entries)) {
|
||||
entries.forEach((entry, entryIndex) => {
|
||||
if (!entry || entry.text == null || entry.text === '') return;
|
||||
String(entry.text).split('\n').forEach((text, lineIndex) => {
|
||||
lines.push({
|
||||
key: `${entry.id ?? entryIndex}:${lineIndex}`,
|
||||
text,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const totalLines = lines.length;
|
||||
const requested = Math.trunc(Number(scroll)) || 0;
|
||||
// Keep at least one line mounted when scrolling to the oldest history.
|
||||
const offset = totalLines === 0
|
||||
? 0
|
||||
: Math.max(0, Math.min(totalLines - 1, requested));
|
||||
const end = Math.max(0, totalLines - offset);
|
||||
const limit = Math.max(1, Math.trunc(Number(maxLines)) || 1);
|
||||
const start = Math.max(0, end - limit);
|
||||
|
||||
return { lines: lines.slice(start, end), totalLines, start, end };
|
||||
}
|
||||
|
||||
function History({ entries, scroll }) {
|
||||
const { lines } = getHistoryWindow(entries, scroll, MAX_VISIBLE_LINES);
|
||||
const visibleText = lines.map((line) => line.text).join('\n');
|
||||
return h(
|
||||
Box,
|
||||
{
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
lines.length ? h(Text, { flexShrink: 0 }, visibleText) : null,
|
||||
);
|
||||
}
|
||||
|
||||
function CommandBar({ prompt, busy, status, store }) {
|
||||
let body;
|
||||
if (prompt) body = h(PromptView, { prompt, store });
|
||||
else if (busy) body = h(StatusLine, { status });
|
||||
else body = h(Text, { dimColor: true }, ' \u203a (idle)');
|
||||
return h(
|
||||
Box,
|
||||
{ borderStyle: 'single', borderColor: 'cyan', flexShrink: 0, flexDirection: 'column' },
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
function TokenFooter({ totals }) {
|
||||
const t = totals || { calls: 0, input: 0, output: 0, cached: 0, cost: 0 };
|
||||
const tok = `\u03a3 in=${fmtInt(t.input)} out=${fmtInt(t.output)}` +
|
||||
(t.cached ? ` cached=${fmtInt(t.cached)}` : '') +
|
||||
` cost=${fmtCost(t.cost)} (${t.calls} call${t.calls === 1 ? '' : 's'})`;
|
||||
const right = h(Text, { dimColor: true }, ' PgUp/PgDn or wheel history \u00b7 End live \u00b7 Ctrl+O mention \u00b7 Ctrl+C cancel');
|
||||
return h(
|
||||
Box,
|
||||
{ borderStyle: 'round', borderColor: 'gray', flexShrink: 0, flexDirection: 'column' },
|
||||
h(Text, { dimColor: true }, ' ' + tok),
|
||||
right,
|
||||
);
|
||||
}
|
||||
|
||||
export function App({ store, sizeOverride }) {
|
||||
const state = useSyncExternalStore(store.subscribe, store.getState);
|
||||
const { suspendTerminal } = useApp();
|
||||
// sizeOverride is a test seam: renderToString only accepts `columns`, so
|
||||
// the tiny-terminal fallback (rows < 12) is unreachable without it.
|
||||
const wsSize = useWindowSize();
|
||||
const columns = (sizeOverride && sizeOverride.columns) || wsSize.columns || 80;
|
||||
const rows = sizeOverride ? (sizeOverride.rows || 24) : (wsSize.rows >= 12 ? wsSize.rows : 24);
|
||||
const liveReasoningLines = formatReasoning(state.reasoningDraft, true, { columns });
|
||||
let transcript = state.entries;
|
||||
if (liveReasoningLines.length > 0) {
|
||||
const liveEntries = liveReasoningLines.map((line, i) => ({
|
||||
id: `live-reasoning-${i}`,
|
||||
text: line,
|
||||
}));
|
||||
transcript = [...transcript, ...liveEntries];
|
||||
}
|
||||
// Expose Ink's terminal-suspend helper so the command loop can run an
|
||||
// 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]);
|
||||
|
||||
// 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.
|
||||
// Ctrl+C with no prompt cancels the running turn (handled by the loop).
|
||||
useInput((input, key) => {
|
||||
const wheelDelta = wheelDeltaFromInput(input);
|
||||
if (wheelDelta !== 0) {
|
||||
// `scroll` counts entries above the live tail: wheel up moves
|
||||
// toward older history (+), while wheel down returns toward the
|
||||
// live tail (-).
|
||||
store.scrollBy(-wheelDelta * WHEEL_STEP);
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && input === 'c' && state.prompt === null) {
|
||||
store.emitCancel();
|
||||
return;
|
||||
}
|
||||
if (key.pageUp) {
|
||||
store.scrollBy(SCROLL_STEP);
|
||||
return;
|
||||
}
|
||||
if (key.pageDown) {
|
||||
store.scrollBy(-SCROLL_STEP);
|
||||
return;
|
||||
}
|
||||
if (key.end) {
|
||||
store.scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// Degraded mode for tiny / non-interactive terminals: skip fixed heights
|
||||
// and clipping so content still renders instead of being squeezed away.
|
||||
if (rows < 12) {
|
||||
let dynamic = null;
|
||||
if (state.prompt) dynamic = h(PromptView, { prompt: state.prompt, store });
|
||||
else if (state.busy) dynamic = h(StatusLine, { status: state.status });
|
||||
return h(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
...transcript.map((entry) => h(Text, { key: entry.id }, entry.text)),
|
||||
dynamic,
|
||||
h(Text, { dimColor: true }, ` in=${fmtInt(state.totals.input)} out=${fmtInt(state.totals.output)} cost=${fmtCost(state.totals.cost)}`),
|
||||
);
|
||||
}
|
||||
|
||||
return h(
|
||||
Box,
|
||||
{ flexDirection: 'column', height: rows, width: columns },
|
||||
h(Header, { meta: state.meta, scroll: state.scroll, columns }),
|
||||
h(History, { entries: transcript, scroll: state.scroll }),
|
||||
h(CommandBar, { prompt: state.prompt, busy: state.busy, status: state.status, store }),
|
||||
h(TokenFooter, { totals: state.totals }),
|
||||
);
|
||||
}
|
||||
134
src/ui/blocks.js
Normal file
134
src/ui/blocks.js
Normal file
@@ -0,0 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
import stringWidth from 'string-width';
|
||||
import wrapAnsi from 'wrap-ansi';
|
||||
|
||||
/**
|
||||
* Transcript block styling for the message and reasoning roles.
|
||||
*
|
||||
* The transcript is rendered by Ink, but its history entries are pre-rendered
|
||||
* strings. This module keeps the role colours in one place and makes it
|
||||
* possible to paint a complete rectangular block even when the content has
|
||||
* nested ANSI styles (Markdown and chalk both emit reset sequences).
|
||||
*/
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
const RESET = '\x1b[0m';
|
||||
const BG_CLOSE = '\x1b[49m';
|
||||
const LEFT_INDENT = ' ';
|
||||
const LEFT_RAIL = '\u258c'; // ▌
|
||||
const DEFAULT_COLUMNS = 80;
|
||||
const BLOCK_PREFIX_WIDTH = LEFT_INDENT.length + LEFT_RAIL.length + 1;
|
||||
|
||||
/**
|
||||
* Default content width for an 80-column terminal. The four columns outside
|
||||
* this width are the two-space margin, the rail, and the body gutter.
|
||||
*/
|
||||
export const BLOCK_CONTENT_WIDTH = DEFAULT_COLUMNS - BLOCK_PREFIX_WIDTH; // 76
|
||||
|
||||
/**
|
||||
* Keep the roles visually distinct. The rail is deliberately a
|
||||
* different hue from the fill so the role remains identifiable at a glance,
|
||||
* even when the transcript is rendered in a terminal with a subtle theme.
|
||||
*/
|
||||
export const BLOCK_STYLES = Object.freeze({
|
||||
assistant: Object.freeze({
|
||||
background: '\x1b[48;2;30;41;59m', // #1e293b — slate
|
||||
accent: '\x1b[38;2;125;211;252m', // light blue
|
||||
}),
|
||||
user: Object.freeze({
|
||||
background: '\x1b[48;2;49;31;68m', // #311f44 — muted purple
|
||||
accent: '\x1b[38;2;196;181;253m', // lavender
|
||||
}),
|
||||
tool: Object.freeze({
|
||||
background: '\x1b[48;2;27;45;45m', // #1b2d2d — dark teal
|
||||
accent: '\x1b[38;2;251;191;36m', // amber
|
||||
}),
|
||||
reasoning: Object.freeze({
|
||||
background: '\x1b[48;2;54;35;72m', // #362348 — dark plum
|
||||
accent: '\x1b[38;2;232;121;249m', // bright pink
|
||||
}),
|
||||
// Keep the thinking terminology available to callers as well as reasoning.
|
||||
thinking: Object.freeze({
|
||||
background: '\x1b[48;2;54;35;72m', // #362348 — dark plum
|
||||
accent: '\x1b[38;2;232;121;249m', // bright pink
|
||||
}),
|
||||
});
|
||||
|
||||
const stripAnsi = (value) => String(value).replace(ANSI_RE, '');
|
||||
|
||||
/** Resolve the terminal width used for a block when no explicit width is given. */
|
||||
function getColumns(options) {
|
||||
const requested = Number(options.columns ?? process.stdout?.columns ?? DEFAULT_COLUMNS);
|
||||
return Number.isFinite(requested) && requested > 0
|
||||
? Math.trunc(requested)
|
||||
: DEFAULT_COLUMNS;
|
||||
}
|
||||
|
||||
/** Resolve the visible body width, excluding the block's fixed chrome. */
|
||||
export function getBlockContentWidth(options = {}) {
|
||||
const requested = Number(options.width);
|
||||
if (Number.isFinite(requested) && requested > 0) return Math.trunc(requested);
|
||||
return Math.max(1, getColumns(options) - BLOCK_PREFIX_WIDTH);
|
||||
}
|
||||
|
||||
function contentLines(content) {
|
||||
const source = Array.isArray(content) ? content.join('\n') : (content ?? '');
|
||||
return String(source)
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/\n+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the complete source before painting it. Ink must not be allowed to do
|
||||
* the final wrap: when it wraps a pre-painted line, the continuation starts
|
||||
* without the rail/background. wrap-ansi carries SGR state across the
|
||||
* explicit newlines while keeping every resulting line inside the body width.
|
||||
*/
|
||||
function wrappedContentLines(content, width) {
|
||||
return wrapAnsi(contentLines(content), width, { trim: false, hard: true }).split('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint one line as:
|
||||
*
|
||||
* [left indent][accent rail][background + one-space body][close]
|
||||
*
|
||||
* A full ANSI reset also clears a background. Re-open the background after
|
||||
* every reset so Markdown/chalk styling cannot make the block disappear in
|
||||
* the middle of a line.
|
||||
*/
|
||||
function paintLine(line, style, width) {
|
||||
const visible = stringWidth(line);
|
||||
const padded = visible < width ? line + ' '.repeat(width - visible) : line;
|
||||
const restored = padded.replace(/\x1b\[0m/g, RESET + style.background);
|
||||
|
||||
return LEFT_INDENT
|
||||
+ style.background
|
||||
+ style.accent
|
||||
+ LEFT_RAIL
|
||||
+ RESET
|
||||
+ style.background
|
||||
+ ' '
|
||||
+ restored
|
||||
+ BG_CLOSE
|
||||
+ RESET;
|
||||
}
|
||||
|
||||
/** Render `content` as a left-railed, rectangular role block. */
|
||||
export function renderBlock(kind, content, options = {}) {
|
||||
const style = BLOCK_STYLES[kind];
|
||||
if (!style) throw new Error(`Unknown transcript block kind: ${kind}`);
|
||||
|
||||
const width = getBlockContentWidth(options);
|
||||
|
||||
return wrappedContentLines(content, width)
|
||||
.map((line) => paintLine(line, style, width))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export const renderAssistantBlock = (content, options) => renderBlock('assistant', content, options);
|
||||
export const renderUserBlock = (content, options) => renderBlock('user', content, options);
|
||||
export const renderToolBlock = (content, options) => renderBlock('tool', content, options);
|
||||
export const renderReasoningBlock = (content, options) => renderBlock('reasoning', content, options);
|
||||
export const renderThinkingBlock = (content, options) => renderReasoningBlock(content, options);
|
||||
38
src/ui/editor.js
Normal file
38
src/ui/editor.js
Normal file
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Compose or paste multiline text in the user's $VISUAL/$EDITOR.
|
||||
*
|
||||
* Used by the /paste command. Returns the saved buffer as a string (empty if
|
||||
* the user saved nothing). Must be run inside `ui.runExternally(...)` so Ink
|
||||
* releases the terminal while the editor owns it.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
export function composeInEditor(initial = '') {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fido-paste-'));
|
||||
const file = path.join(dir, 'message.md');
|
||||
fs.writeFileSync(file, initial);
|
||||
|
||||
const editor =
|
||||
process.env.VISUAL ||
|
||||
process.env.EDITOR ||
|
||||
(process.platform === 'win32' ? 'notepad' : 'vim');
|
||||
|
||||
try {
|
||||
// `shell: true` lets multi-word $EDITOR values (e.g. "code --wait")
|
||||
// work; the path is quoted so spaces survive.
|
||||
spawnSync(`${editor} ${JSON.stringify(file)}`, { stdio: 'inherit', shell: true });
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/ui/index.js
Normal file
103
src/ui/index.js
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* UI singleton \u2014 the imperative face of the Ink app.
|
||||
*
|
||||
* The REPL's command loop and the tools import `ui` and call these methods;
|
||||
* they never touch React or Ink directly. `mount()` renders the <App>, and the
|
||||
* rest of the methods push transcript lines or install prompts through the
|
||||
* shared store (see ./store.js and ./app.js).
|
||||
*
|
||||
* Prompt methods return promises:
|
||||
* - `ask()` resolves with the submitted string, or `null` if the user pressed
|
||||
* Ctrl+C (the caller should then exit).
|
||||
* - `input()`/`select()`/`confirm()` resolve with the answer, and reject with
|
||||
* an Error on Ctrl+C (matching the old @inquirer behaviour).
|
||||
*
|
||||
* Written without JSX (the project runs straight from source).
|
||||
*/
|
||||
|
||||
import { createElement as h } from 'react';
|
||||
import { render } from 'ink';
|
||||
|
||||
import { createStore } from './store.js';
|
||||
import { App } from './app.js';
|
||||
import { composeInEditor } from './editor.js';
|
||||
|
||||
const store = createStore();
|
||||
let instance = null;
|
||||
|
||||
/** Mount the Ink app. Safe to call once. */
|
||||
export function mount() {
|
||||
if (instance) return instance;
|
||||
// exitOnCtrlC:false \u2014 we handle Ctrl+C ourselves so it can cancel a turn
|
||||
// or back out of an overlay instead of killing the process.
|
||||
// alternateScreen:true \u2014 fullscreen fixed panes (header / history /
|
||||
// command bar / footer) on a separate buffer; the shell scrollback is
|
||||
// restored on exit. Ignored automatically when non-interactive.
|
||||
instance = render(h(App, { store }), { exitOnCtrlC: false, alternateScreen: true });
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Unmount the Ink app and wait for the final frame to flush. */
|
||||
export async function unmount() {
|
||||
if (!instance) return;
|
||||
const inst = instance;
|
||||
instance = null;
|
||||
inst.unmount();
|
||||
try {
|
||||
await inst.waitUntilExit();
|
||||
} catch {
|
||||
/* exit(error) is not used; ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export const ui = {
|
||||
/** Append a history entry to the transcript pane. */
|
||||
log: (text) => store.log(text),
|
||||
|
||||
/** Update the live status line (shown while busy). */
|
||||
status: (text) => store.setStatus(text),
|
||||
|
||||
/** Toggle "model working" mode; clearing it also clears the status line. */
|
||||
busy: (value) => store.setBusy(value),
|
||||
|
||||
/** Accumulate one usage record into the token/cost footer totals. */
|
||||
addUsage: (u) => store.addUsage(u),
|
||||
|
||||
/** Set header chrome (model / workspace / reasoning). */
|
||||
setMeta: (patch) => store.setMeta(patch),
|
||||
|
||||
/** Update live streaming reasoning text shown in the history pane. */
|
||||
setReasoning: (text) => store.setReasoningDraft(text),
|
||||
|
||||
/** Finalize streaming reasoning into permanent history entries. */
|
||||
finalizeReasoning: (text) => store.finalizeReasoning(text),
|
||||
|
||||
/** Clear live reasoning without committing to history. */
|
||||
clearReasoning: () => store.clearReasoning(),
|
||||
|
||||
/** The main prompt. Resolves with the submitted string, or null on Ctrl+C. */
|
||||
ask: (opts = {}) => store.prompt({ type: 'main', message: 'You', ...opts }),
|
||||
|
||||
/** A one-line free-text prompt. */
|
||||
input: (opts = {}) => store.prompt({ type: 'input', ...opts }),
|
||||
|
||||
/** A single-select list prompt. */
|
||||
select: (opts = {}) => store.prompt({ type: 'select', ...opts }),
|
||||
|
||||
/** A yes/no confirmation prompt. */
|
||||
confirm: (opts = {}) => store.prompt({ type: 'confirm', ...opts }),
|
||||
|
||||
/** Register a Ctrl+C (turn-running) handler; returns an unsubscribe fn. */
|
||||
onCancel: (fn) => store.onCancel(fn),
|
||||
|
||||
/** Hand the terminal to `fn` (e.g. an external editor), then restore it. */
|
||||
runExternally: (fn) => store.runExternally(fn),
|
||||
|
||||
/** Compose multiline text in $EDITOR, inside a terminal suspension. */
|
||||
composeInEditor: (initial) => store.runExternally(() => composeInEditor(initial)),
|
||||
|
||||
/** The underlying store (for tests / advanced use). */
|
||||
store,
|
||||
};
|
||||
383
src/ui/main-input.js
Normal file
383
src/ui/main-input.js
Normal file
@@ -0,0 +1,383 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The main prompt: a single-line ink-text-input with two overlays.
|
||||
*
|
||||
* 1. Slash-command autocomplete. While the draft starts with "/" (and no
|
||||
* space has been typed) the matching commands are listed inline.
|
||||
* \u2191 / \u2193 move the highlight
|
||||
* Tab complete the highlighted command
|
||||
* Enter submit
|
||||
* The filter term is frozen while you navigate so completing a command
|
||||
* with Tab does not collapse the list.
|
||||
*
|
||||
* 2. In-prompt file browser (opened with Ctrl+O) with two modes, browse and
|
||||
* line-select. The user picks a whole file (`@path`) or a line range
|
||||
* (`@path:start-end`), and the reference is appended to the draft.
|
||||
*
|
||||
* ink-text-input consumes printable keys and left/right/backspace itself, but
|
||||
* ignores \u2191/\u2193/Tab (and Ctrl+C). Those therefore reach our own `useInput`
|
||||
* handler below. Ctrl+O is the one control chord ink-text-input would mistype as
|
||||
* a literal "o", so a tiny <CtrlOGate> is rendered *before* the text input: its
|
||||
* useEffect runs first, registering its input listener first, so it sees Ctrl+O
|
||||
* before ink-text-input does and can suppress the stray character.
|
||||
*
|
||||
* Written without JSX (the project runs straight from source).
|
||||
*/
|
||||
|
||||
import { createElement as h, useRef, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { SafeTextInput } from './safe-text-input.js';
|
||||
import { renderUserBlock } from './blocks.js';
|
||||
|
||||
import { COMMANDS, filterCommands } from '../slash-commands.js';
|
||||
import {
|
||||
listEntries,
|
||||
readLines,
|
||||
formatMention,
|
||||
normalizeRange,
|
||||
windowStart,
|
||||
parentOf,
|
||||
} from '../file-browser.js';
|
||||
|
||||
const MAX_SUGGESTIONS = 6;
|
||||
const NAME_WIDTH = Math.max(...COMMANDS.map((c) => c.name.length));
|
||||
const BROWSE_WINDOW = 12;
|
||||
const LINE_WINDOW = 18;
|
||||
|
||||
/** The slash-command filter term, or null when the menu should be closed. */
|
||||
function termFrom(line) {
|
||||
if (typeof line !== 'string' || !line.startsWith('/')) return null;
|
||||
if (line.includes(' ')) return null;
|
||||
return line.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* A zero-size gate whose only job is to observe Ctrl+O before ink-text-input.
|
||||
* Rendering it as the first child guarantees its effect (and therefore its
|
||||
* input listener) is registered ahead of the text input's.
|
||||
*/
|
||||
function CtrlOGate({ onOpen }) {
|
||||
useInput((input, key) => {
|
||||
if (key.ctrl && input === 'o') onOpen();
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function BrowseRow({ entry, active }) {
|
||||
const marker = active ? h(Text, { color: 'cyan' }, '\u276f ') : h(Text, null, ' ');
|
||||
let label;
|
||||
if (entry.type === 'parent') label = h(Text, { dimColor: true }, '../');
|
||||
else if (entry.type === 'd') label = h(Text, { color: 'cyan', bold: true }, entry.name + '/');
|
||||
else label = h(Text, null, entry.name);
|
||||
return h(Text, null, marker, label);
|
||||
}
|
||||
|
||||
function LineRow({ line, index, cursor, lo, hi, width }) {
|
||||
const cur = index === cursor ? h(Text, { color: 'cyan' }, '\u276f ') : h(Text, null, ' ');
|
||||
const inRange = lo !== null && index >= lo && index <= hi;
|
||||
const mark = inRange ? h(Text, { color: 'cyan' }, '\u2502 ') : h(Text, null, ' ');
|
||||
const num = h(Text, { dimColor: true }, String(index + 1).padStart(width) + ' ');
|
||||
const text = line.replace(/\t/g, ' ');
|
||||
const body = index === cursor ? h(Text, { color: 'white' }, text) : h(Text, null, text);
|
||||
return h(Text, null, cur, num, mark, body);
|
||||
}
|
||||
|
||||
export function MainInput({ prompt, store }) {
|
||||
const message = prompt.message || 'You';
|
||||
|
||||
const [value, setValue] = useState(prompt.prefill || '');
|
||||
const valueRef = useRef(value);
|
||||
// The input value is local to the prompt. It is only appended to history
|
||||
// when the user submits it.
|
||||
valueRef.current = value;
|
||||
|
||||
// Slash menu: `frozen` keeps the filter term pinned while navigating.
|
||||
const [frozen, setFrozen] = useState(null);
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
// File browser state.
|
||||
const [mode, setMode] = useState('input'); // 'input' | 'browse' | 'lines'
|
||||
const [cwd, setCwd] = useState('');
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [browseActive, setBrowseActive] = useState(0);
|
||||
const [selFile, setSelFile] = useState(null);
|
||||
const [fileLines, setFileLines] = useState([]);
|
||||
const [fileBinary, setFileBinary] = useState(false);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [anchor, setAnchor] = useState(null);
|
||||
const [notice, setNotice] = useState('');
|
||||
|
||||
// Draft captured when the browser opens, so a cancel restores it.
|
||||
const draftRef = useRef('');
|
||||
// Set by <CtrlOGate> just before ink-text-input would insert the stray "o".
|
||||
const ctrlORef = useRef(false);
|
||||
const modeRef = useRef('input');
|
||||
modeRef.current = mode;
|
||||
const cwdRef = useRef(cwd);
|
||||
cwdRef.current = cwd;
|
||||
|
||||
const term = frozen !== null ? frozen : termFrom(value);
|
||||
const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS);
|
||||
const count = suggestions.length;
|
||||
const activeIdx = count ? active % count : 0;
|
||||
|
||||
// \u2500\u2500 Browser helpers \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\u2500\u2500\u2500\u2500\u2500
|
||||
const openDir = (rel) => {
|
||||
setCwd(rel);
|
||||
setEntries(listEntries(rel));
|
||||
setBrowseActive(0);
|
||||
setNotice('');
|
||||
};
|
||||
|
||||
const openFile = (rel) => {
|
||||
const { lines, binary, error } = readLines(rel);
|
||||
if (error) {
|
||||
setNotice(`could not read ${rel}`);
|
||||
setEntries(listEntries(cwdRef.current));
|
||||
return;
|
||||
}
|
||||
setSelFile(rel);
|
||||
setFileLines(lines);
|
||||
setFileBinary(binary);
|
||||
setCursor(0);
|
||||
setAnchor(null);
|
||||
setNotice(binary ? 'binary file \u2014 whole-file mention only' : '');
|
||||
setMode('lines');
|
||||
};
|
||||
|
||||
const cancelToInput = () => {
|
||||
setValue(draftRef.current);
|
||||
setMode('input');
|
||||
setNotice('');
|
||||
};
|
||||
|
||||
// ink-text-input does not expose the caret position, so mentions are
|
||||
// appended at the end of the draft (space-separated) rather than spliced at
|
||||
// the caret.
|
||||
const insertMention = (text) => {
|
||||
const draft = draftRef.current || '';
|
||||
const pad = draft.length && !/\s$/.test(draft) ? ' ' : '';
|
||||
const next = draft + pad + text;
|
||||
setValue(next);
|
||||
setFrozen(null);
|
||||
setActive(0);
|
||||
setMode('input');
|
||||
setNotice('');
|
||||
};
|
||||
|
||||
const openBrowser = () => {
|
||||
ctrlORef.current = true;
|
||||
draftRef.current = valueRef.current;
|
||||
openDir('');
|
||||
setMode('browse');
|
||||
};
|
||||
|
||||
const handleChange = (next) => {
|
||||
// Swallow the literal "o" ink-text-input inserts for Ctrl+O.
|
||||
if (ctrlORef.current) {
|
||||
ctrlORef.current = false;
|
||||
return;
|
||||
}
|
||||
setValue(next);
|
||||
setFrozen(null);
|
||||
setActive(0);
|
||||
};
|
||||
|
||||
const submit = (submitted) => {
|
||||
store.resolve(
|
||||
submitted,
|
||||
submitted
|
||||
? renderUserBlock(`\u203a ${message} ${String(submitted).replace(/\n/g, '\n ')}`)
|
||||
: '',
|
||||
);
|
||||
};
|
||||
|
||||
// \u2500\u2500 Key handling (browse / lines / input special keys) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
useInput((input, key) => {
|
||||
// Ctrl+C: at the prompt it exits; inside an overlay it backs out first.
|
||||
if (key.ctrl && input === 'c') {
|
||||
if (modeRef.current === 'input') store.resolve(null);
|
||||
else cancelToInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (modeRef.current === 'browse') {
|
||||
const n = entries.length;
|
||||
if (key.upArrow) {
|
||||
if (n) setBrowseActive((b) => (b - 1 + n) % n);
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
if (n) setBrowseActive((b) => (b + 1) % n);
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
const entry = entries[browseActive];
|
||||
if (!entry) return;
|
||||
if (entry.type === 'f') openFile(entry.rel);
|
||||
else openDir(entry.rel);
|
||||
return;
|
||||
}
|
||||
if (key.tab) {
|
||||
const entry = entries[browseActive];
|
||||
if (entry && entry.type === 'f') insertMention(formatMention(entry.rel));
|
||||
return;
|
||||
}
|
||||
if (key.backspace) {
|
||||
if (cwd) openDir(parentOf(cwd));
|
||||
return;
|
||||
}
|
||||
if (key.escape) cancelToInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (modeRef.current === 'lines') {
|
||||
const n = fileLines.length;
|
||||
if (key.upArrow) {
|
||||
setCursor((c) => Math.max(0, c - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setCursor((c) => Math.max(0, Math.min(n - 1, c + 1)));
|
||||
return;
|
||||
}
|
||||
if (!fileBinary && n > 0) {
|
||||
if (input === 'v') {
|
||||
setAnchor((a) => (a === null ? cursor : null));
|
||||
return;
|
||||
}
|
||||
if (input === 'g') {
|
||||
setCursor(0);
|
||||
return;
|
||||
}
|
||||
if (input === 'G') {
|
||||
setCursor(n - 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (key.return) {
|
||||
let text;
|
||||
if (anchor !== null && n > 0) {
|
||||
const { start, end } = normalizeRange(anchor, cursor);
|
||||
text = formatMention(selFile, start, end);
|
||||
} else {
|
||||
text = formatMention(selFile);
|
||||
}
|
||||
insertMention(text);
|
||||
return;
|
||||
}
|
||||
if (key.escape || key.backspace) {
|
||||
setMode('browse');
|
||||
setNotice('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// \u2500\u2500 Input mode: special keys not consumed by ink-text-input \u2500\u2500
|
||||
// (Ctrl+O is handled by <CtrlOGate> so it runs before the text input.)
|
||||
if (count > 0 && (key.upArrow || key.downArrow)) {
|
||||
const delta = key.upArrow ? -1 : 1;
|
||||
setActive((a) => (a + delta + count) % count);
|
||||
return;
|
||||
}
|
||||
if (key.tab) {
|
||||
if (count === 0) return;
|
||||
const pick = suggestions[activeIdx];
|
||||
setValue(pick.name);
|
||||
setFrozen(term); // keep the menu open while completing repeatedly
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// \u2500\u2500 Render \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
if (mode === 'browse') {
|
||||
const n = entries.length;
|
||||
const start = windowStart(browseActive, n, BROWSE_WINDOW);
|
||||
const end = Math.min(n, start + BROWSE_WINDOW);
|
||||
const rows = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
rows.push(h(BrowseRow, { key: i, entry: entries[i], active: i === browseActive }));
|
||||
}
|
||||
const where = cwd ? `${cwd}/` : '(workspace root)';
|
||||
return h(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
h(Text, { color: 'cyan', bold: true }, ' \ud83d\udcc1 File browser ', h(Text, { dimColor: true }, where)),
|
||||
rows.length ? h(Box, { flexDirection: 'column' }, ...rows) : h(Text, { dimColor: true }, ' (empty directory)'),
|
||||
notice ? h(Text, { dimColor: true }, ' ' + notice) : null,
|
||||
h(Text, { dimColor: true }, ' \u2191/\u2193 move \u00b7 Enter open \u00b7 Tab mention \u00b7 Backspace up \u00b7 Esc cancel'),
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'lines') {
|
||||
const n = fileLines.length;
|
||||
const children = [
|
||||
h(Text, { key: 'title' }, h(Text, { color: 'cyan', bold: true }, ' ' + selFile), h(Text, { dimColor: true }, ' \u2014 select lines')),
|
||||
];
|
||||
if (fileBinary) {
|
||||
children.push(h(Text, { key: 'bin', dimColor: true }, ' (binary file \u2014 Enter mentions the whole file)'));
|
||||
} else if (n === 0) {
|
||||
children.push(h(Text, { key: 'empty', dimColor: true }, ' (empty file \u2014 Enter mentions the whole file)'));
|
||||
} else {
|
||||
const start = windowStart(cursor, n, LINE_WINDOW);
|
||||
const end = Math.min(n, start + LINE_WINDOW);
|
||||
const width = String(n).length;
|
||||
const lo = anchor === null ? null : Math.min(anchor, cursor);
|
||||
const hi = anchor === null ? null : Math.max(anchor, cursor);
|
||||
const rows = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
rows.push(
|
||||
h(LineRow, {
|
||||
key: i,
|
||||
line: fileLines[i],
|
||||
index: i,
|
||||
cursor,
|
||||
lo,
|
||||
hi,
|
||||
width,
|
||||
}),
|
||||
);
|
||||
}
|
||||
children.push(h(Box, { key: 'rows', flexDirection: 'column' }, ...rows));
|
||||
}
|
||||
if (notice) children.push(h(Text, { key: 'notice', dimColor: true }, ' ' + notice));
|
||||
children.push(
|
||||
h(Text, { key: 'help', dimColor: true }, ' \u2191/\u2193 move \u00b7 v mark \u00b7 g/G top/bottom \u00b7 Enter insert \u00b7 Esc back'),
|
||||
);
|
||||
return h(Box, { flexDirection: 'column' }, ...children);
|
||||
}
|
||||
|
||||
// \u2500\u2500 Input view \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
const menu = count
|
||||
? h(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
...suggestions.map((c, i) =>
|
||||
h(
|
||||
Text,
|
||||
{ key: c.name },
|
||||
i === activeIdx ? h(Text, { color: 'cyan' }, ' \u276f ') : h(Text, null, ' '),
|
||||
i === activeIdx
|
||||
? h(Text, { bold: true, color: 'cyan' }, c.name.padEnd(NAME_WIDTH + 2))
|
||||
: h(Text, { bold: true }, c.name.padEnd(NAME_WIDTH + 2)),
|
||||
h(Text, { dimColor: true }, c.description),
|
||||
),
|
||||
),
|
||||
count > 1 ? h(Text, { dimColor: true }, ' \u2191/\u2193 choose \u00b7 Tab complete') : null,
|
||||
)
|
||||
: null;
|
||||
|
||||
return h(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
menu,
|
||||
h(
|
||||
Box,
|
||||
null,
|
||||
h(CtrlOGate, { key: 'ctrl-o', onOpen: openBrowser }),
|
||||
h(Text, { color: 'cyan', bold: true }, '\u203a ' + message + ' '),
|
||||
h(SafeTextInput, { value, onChange: handleChange, onSubmit: submit }),
|
||||
),
|
||||
);
|
||||
}
|
||||
45
src/ui/mouse.js
Normal file
45
src/ui/mouse.js
Normal file
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Helpers for terminal mouse reports.
|
||||
*
|
||||
* We enable SGR mouse reporting (mode 1006), so wheel events arrive as
|
||||
* CSI sequences such as ESC [ < 65 ; 88 ; 20 M. Ink exposes the sequence to
|
||||
* useInput as text (without the leading ESC), which is normally useful for
|
||||
* key handling but is dangerous for text inputs: an unknown CSI sequence can
|
||||
* be inserted into the draft.
|
||||
*/
|
||||
|
||||
const MOUSE_SEQUENCE_RE = /\x1b?\[<(\d+);\d+;\d+[Mm]/g;
|
||||
const COMPLETE_MOUSE_SEQUENCE_RE = /^\x1b?\[<(\d+);\d+;\d+[Mm]$/;
|
||||
|
||||
/**
|
||||
* Return the scroll direction for an SGR mouse event.
|
||||
*
|
||||
* -1 means wheel up (older history), +1 means wheel down (toward the live
|
||||
* tail), and 0 means the event is not a wheel event. The 64 bit identifies
|
||||
* wheel buttons; the low bit is the up/down distinction. Mode 1000 does not
|
||||
* 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;
|
||||
|
||||
const code = Number(match[1]);
|
||||
if ((code & 64) === 0) return 0;
|
||||
return code & 1 ? 1 : -1;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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, '');
|
||||
}
|
||||
128
src/ui/prompts.js
Normal file
128
src/ui/prompts.js
Normal file
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Simple Ink prompt components used by the REPL and by tools that need to ask
|
||||
* the user something mid-turn (ask_user, ask_an_expert, bash_exec approval,
|
||||
* /suspend, /recall, the cancel-confirm).
|
||||
*
|
||||
* Each component receives the active `prompt` object (installed in the store)
|
||||
* and the `store`. It renders the prompt and, on submit, calls
|
||||
* `store.resolve(value)`. Text prompts add a single permanent
|
||||
* history block only when submitted. Ctrl+C calls `store.cancel()` which rejects the
|
||||
* prompt's promise — mirroring the old @inquirer behaviour, so the existing
|
||||
* try/catch call sites keep working.
|
||||
*
|
||||
* Written without JSX (the project runs straight from source with no build
|
||||
* step), so elements are built with React.createElement.
|
||||
*/
|
||||
|
||||
import { createElement as h, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { SafeTextInput } from './safe-text-input.js';
|
||||
import { renderUserBlock } from './blocks.js';
|
||||
|
||||
/** A single-line free-text prompt, with mouse reports filtered out. */
|
||||
export function TextPrompt({ prompt, store }) {
|
||||
const accent = prompt.color || 'cyan';
|
||||
const [value, setValue] = useState(prompt.prefill || '');
|
||||
|
||||
const handleChange = (next) => {
|
||||
setValue(next);
|
||||
};
|
||||
|
||||
const submit = (submitted) => {
|
||||
const text = submitted
|
||||
? renderUserBlock(`\u203a ${prompt.message} ${String(submitted).replace(/\n/g, '\n ')}`)
|
||||
: '';
|
||||
store.resolve(submitted, text);
|
||||
};
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.ctrl && input === 'c') store.cancel();
|
||||
});
|
||||
|
||||
return h(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
prompt.help ? h(Text, { dimColor: true }, ' ' + prompt.help) : null,
|
||||
h(
|
||||
Box,
|
||||
null,
|
||||
h(Text, { color: accent }, ' \u203a '),
|
||||
h(Text, { bold: true, color: accent }, prompt.message + ' '),
|
||||
h(SafeTextInput, {
|
||||
value,
|
||||
onChange: handleChange,
|
||||
onSubmit: submit,
|
||||
placeholder: prompt.placeholder || '',
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A vertical single-select list (arrow keys + Enter). */
|
||||
export function SelectPrompt({ prompt, store }) {
|
||||
const accent = prompt.color || 'cyan';
|
||||
const choices = prompt.choices || [];
|
||||
const [active, setActive] = useState(() => {
|
||||
if (prompt.default === undefined) return 0;
|
||||
const i = choices.findIndex((c) => c.value === prompt.default);
|
||||
return i < 0 ? 0 : i;
|
||||
});
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.ctrl && input === 'c') return store.cancel();
|
||||
if (key.upArrow) {
|
||||
setActive((a) => (a - 1 + choices.length) % choices.length);
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setActive((a) => (a + 1) % choices.length);
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
const pick = choices[active];
|
||||
if (pick) store.resolve(pick.value);
|
||||
}
|
||||
});
|
||||
|
||||
return h(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
h(Text, { bold: true, color: accent }, ' ' + prompt.message),
|
||||
...choices.map((c, i) =>
|
||||
h(
|
||||
Text,
|
||||
{ key: i },
|
||||
i === active ? h(Text, { color: accent }, ' \u276f ') : h(Text, null, ' '),
|
||||
i === active ? h(Text, { color: accent, bold: true }, c.name) : h(Text, null, c.name),
|
||||
),
|
||||
),
|
||||
h(Text, { dimColor: true }, ' \u2191/\u2193 choose \u00b7 Enter select \u00b7 Ctrl+C cancel'),
|
||||
);
|
||||
}
|
||||
|
||||
/** A yes/no confirmation (y / n / Enter, default configurable). */
|
||||
export function ConfirmPrompt({ prompt, store }) {
|
||||
const accent = prompt.color || 'cyan';
|
||||
const def = prompt.default !== false; // default yes unless explicitly false
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.ctrl && input === 'c') return store.cancel();
|
||||
if (input === 'y' || input === 'Y') return store.resolve(true);
|
||||
if (input === 'n' || input === 'N') return store.resolve(false);
|
||||
if (key.return) return store.resolve(def);
|
||||
});
|
||||
|
||||
return h(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
h(Text, { bold: true, color: accent }, ' ' + prompt.message),
|
||||
h(
|
||||
Text,
|
||||
{ dimColor: true },
|
||||
def ? ' (Y/n) ' : ' (y/N) ',
|
||||
h(Text, { color: accent }, 'Ctrl+C cancel'),
|
||||
),
|
||||
);
|
||||
}
|
||||
32
src/ui/safe-text-input.js
Normal file
32
src/ui/safe-text-input.js
Normal file
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ink-text-input does not know about SGR mouse reports. If one reaches it,
|
||||
* the report is treated as ordinary printable input and appears in the
|
||||
* prompt. Filter it before the parent sees the new value and remount the
|
||||
* underlying input so its private cursor state cannot retain the length of
|
||||
* the discarded report.
|
||||
*/
|
||||
|
||||
import { createElement as h, useState } from 'react';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
import { stripMouseSequences } from './mouse.js';
|
||||
|
||||
export function SafeTextInput(props) {
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const handleChange = (next) => {
|
||||
const cleaned = stripMouseSequences(next);
|
||||
if (cleaned !== next) {
|
||||
setGeneration((value) => value + 1);
|
||||
}
|
||||
props.onChange?.(cleaned);
|
||||
};
|
||||
|
||||
return h(TextInput, {
|
||||
...props,
|
||||
key: generation,
|
||||
onChange: handleChange,
|
||||
});
|
||||
}
|
||||
227
src/ui/store.js
Normal file
227
src/ui/store.js
Normal file
@@ -0,0 +1,227 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* External store for the Ink UI.
|
||||
*
|
||||
* Fido's command loop and its tools are plain async functions, not React
|
||||
* components, so they talk to the view through this store rather than writing
|
||||
* to the terminal directly:
|
||||
*
|
||||
* - `log(text)` append a permanent line to the transcript (history
|
||||
* pane, windowed to the terminal height; store keeps
|
||||
* the last 1000 entries, the view mounts the tail).
|
||||
* - `setStatus(text)` update the transient status line shown while a turn
|
||||
* is running.
|
||||
* - `setBusy(bool)` toggle between "model working" and "awaiting input".
|
||||
* - `scrollBy(n)` / `scrollToBottom()` scroll the history pane up/down.
|
||||
* - `addUsage(u)` accumulate token/cost totals for the footer box.
|
||||
* - `setMeta(patch)` header chrome (model / workspace / reasoning).
|
||||
* - `prompt(request)` install a prompt the <App> renders and return a
|
||||
* promise that settles when the user answers (resolve)
|
||||
* or cancels with Ctrl+C (reject).
|
||||
* - `onCancel(fn)` register a listener for Ctrl+C while no prompt is
|
||||
* shown (i.e. a turn is running) so the turn is aborted
|
||||
* instead of exiting.
|
||||
* - `runExternally(fn)` hand the terminal to a child process (e.g. $EDITOR)
|
||||
* via Ink's `suspendTerminal`, then restore it.
|
||||
*
|
||||
* The store is intentionally tiny: immutable snapshot state plus a Set of
|
||||
* subscribers. React subscribes through `useSyncExternalStore`.
|
||||
*/
|
||||
|
||||
import { countHistoryLines, formatReasoning } from './app.js';
|
||||
|
||||
export function createStore() {
|
||||
let idSeq = 0;
|
||||
const MAX_ENTRIES = 1000;
|
||||
let state = {
|
||||
entries: [],
|
||||
prompt: null,
|
||||
reasoningDraft: '',
|
||||
busy: false,
|
||||
status: '',
|
||||
// Rendered lines hidden above the live tail. 0 = follow.
|
||||
scroll: 0,
|
||||
totals: { calls: 0, input: 0, output: 0, cached: 0, cost: 0 },
|
||||
meta: { model: '', workspace: '', reasoning: '' },
|
||||
};
|
||||
const listeners = new Set();
|
||||
const cancelListeners = new Set();
|
||||
|
||||
const setState = (patch) => {
|
||||
state = { ...state, ...patch };
|
||||
for (const l of listeners) l();
|
||||
};
|
||||
|
||||
const clampScroll = (scroll, entries) => {
|
||||
if (!(scroll > 0)) return 0;
|
||||
const totalLines = countHistoryLines(entries);
|
||||
return Math.max(0, Math.min(scroll, Math.max(0, totalLines - 1)));
|
||||
};
|
||||
|
||||
const appendEntry = (entries, text) => {
|
||||
const str = text == null ? '' : String(text);
|
||||
if (!str) return entries;
|
||||
let next = [...entries, { id: ++idSeq, text: str }];
|
||||
// Bound memory + per-frame reconciler work (fullscreen re-renders
|
||||
// history every frame, unlike <Static>'s write-once). Drop oldest.
|
||||
if (next.length > MAX_ENTRIES) next = next.slice(-MAX_ENTRIES);
|
||||
return next;
|
||||
};
|
||||
|
||||
const store = {
|
||||
getState: () => state,
|
||||
subscribe: (l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
|
||||
/** Append a permanent transcript line (a pre-rendered ANSI string). */
|
||||
log(text) {
|
||||
let entries = state.entries;
|
||||
if (state.reasoningDraft && state.reasoningDraft.trim()) {
|
||||
const lines = formatReasoning(state.reasoningDraft, false);
|
||||
for (const line of lines) {
|
||||
entries = appendEntry(entries, line);
|
||||
}
|
||||
}
|
||||
entries = appendEntry(entries, text);
|
||||
if (entries === state.entries && !state.reasoningDraft) return;
|
||||
setState({
|
||||
entries,
|
||||
reasoningDraft: '',
|
||||
scroll: clampScroll(state.scroll, entries),
|
||||
});
|
||||
},
|
||||
|
||||
/** Update the transient status line (shown only while busy). */
|
||||
setStatus(status) {
|
||||
if (state.status !== status) setState({ status: status || '' });
|
||||
},
|
||||
|
||||
setBusy(busy) {
|
||||
const next = !!busy;
|
||||
setState({ busy: next, status: next ? state.status : '' });
|
||||
},
|
||||
|
||||
/** Scroll history up (+) / down (-) by N rendered lines. Clamped. */
|
||||
scrollBy(delta) {
|
||||
const d = Math.trunc(delta) || 0;
|
||||
if (!d) return;
|
||||
setState({ scroll: clampScroll(state.scroll + d, state.entries) });
|
||||
},
|
||||
|
||||
/** Jump back to the live tail. */
|
||||
scrollToBottom() {
|
||||
if (state.scroll !== 0) setState({ scroll: 0 });
|
||||
},
|
||||
|
||||
/** Accumulate one model-call usage record into the footer totals. */
|
||||
addUsage(u) {
|
||||
if (!u) return;
|
||||
const t = state.totals;
|
||||
setState({
|
||||
totals: {
|
||||
calls: t.calls + 1,
|
||||
input: t.input + (u.input || 0),
|
||||
output: t.output + (u.output || 0),
|
||||
cached: t.cached + (u.cached || 0),
|
||||
cost: t.cost + (u.cost || 0),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** Static chrome data for the header (model / workspace / reasoning). */
|
||||
setMeta(patch) {
|
||||
setState({ meta: { ...state.meta, ...(patch || {}) } });
|
||||
},
|
||||
|
||||
/** Install a prompt; the returned promise settles when it is answered. */
|
||||
prompt(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const prompt = { ...request, resolve, reject };
|
||||
setState({ prompt });
|
||||
});
|
||||
},
|
||||
|
||||
/** Update the live reasoning text shown in history while the model reasons. */
|
||||
setReasoningDraft(value) {
|
||||
const reasoningDraft = value == null ? '' : String(value);
|
||||
if (reasoningDraft !== state.reasoningDraft) {
|
||||
setState({ reasoningDraft });
|
||||
}
|
||||
},
|
||||
|
||||
/** Finalize live reasoning into permanent history entries. */
|
||||
finalizeReasoning(customText) {
|
||||
const raw = customText !== undefined ? customText : state.reasoningDraft;
|
||||
if (!raw || !raw.trim()) {
|
||||
if (state.reasoningDraft) setState({ reasoningDraft: '' });
|
||||
return;
|
||||
}
|
||||
const lines = formatReasoning(raw, false);
|
||||
let entries = state.entries;
|
||||
for (const line of lines) {
|
||||
entries = appendEntry(entries, line);
|
||||
}
|
||||
setState({
|
||||
entries,
|
||||
reasoningDraft: '',
|
||||
scroll: clampScroll(state.scroll, entries.length),
|
||||
});
|
||||
},
|
||||
|
||||
/** Clear live reasoning without committing to history. */
|
||||
clearReasoning() {
|
||||
if (state.reasoningDraft) {
|
||||
setState({ reasoningDraft: '' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called by the view when the user answers the active prompt.
|
||||
* `historyText` is appended as one permanent transcript entry as the
|
||||
* prompt closes.
|
||||
*/
|
||||
resolve(value, historyText) {
|
||||
const p = state.prompt;
|
||||
if (!p) return;
|
||||
const entries = appendEntry(state.entries, historyText);
|
||||
setState({
|
||||
...(entries === state.entries ? {} : { entries }),
|
||||
prompt: null,
|
||||
scroll: clampScroll(state.scroll, entries.length),
|
||||
});
|
||||
p.resolve(value);
|
||||
},
|
||||
|
||||
/** Called by the view when the user cancels the active prompt. */
|
||||
cancel() {
|
||||
const p = state.prompt;
|
||||
if (!p) return;
|
||||
setState({
|
||||
prompt: null,
|
||||
scroll: clampScroll(state.scroll, state.entries),
|
||||
});
|
||||
p.reject(new Error('User cancelled'));
|
||||
},
|
||||
|
||||
/** Ctrl+C while no prompt is shown: notify cancel listeners. */
|
||||
emitCancel() {
|
||||
for (const l of cancelListeners) l();
|
||||
},
|
||||
onCancel(fn) {
|
||||
cancelListeners.add(fn);
|
||||
return () => cancelListeners.delete(fn);
|
||||
},
|
||||
|
||||
// Bridge to Ink's `suspendTerminal`, registered by <App> on mount.
|
||||
_suspend: null,
|
||||
runExternally(fn) {
|
||||
if (!store._suspend) throw new Error('UI is not mounted');
|
||||
return store._suspend(fn);
|
||||
},
|
||||
};
|
||||
|
||||
return store;
|
||||
}
|
||||
31
test/mouse.test.js
Normal file
31
test/mouse.test.js
Normal file
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
wheelDeltaFromInput,
|
||||
containsMouseSequence,
|
||||
stripMouseSequences,
|
||||
} from '../src/ui/mouse.js';
|
||||
|
||||
test('decodes SGR wheel direction', () => {
|
||||
assert.equal(wheelDeltaFromInput('\x1b[<64;88;20M'), -1);
|
||||
assert.equal(wheelDeltaFromInput('[<65;88;20M'), 1);
|
||||
// Some terminals add modifier bits to the button code.
|
||||
assert.equal(wheelDeltaFromInput('[<96;88;20M'), -1);
|
||||
assert.equal(wheelDeltaFromInput('[<97;88;20M'), 1);
|
||||
});
|
||||
|
||||
test('ignores non-wheel and incomplete mouse input', () => {
|
||||
assert.equal(wheelDeltaFromInput('[<0;1;1M'), 0);
|
||||
assert.equal(wheelDeltaFromInput('[<64;1;1'), 0);
|
||||
assert.equal(wheelDeltaFromInput('hello'), 0);
|
||||
});
|
||||
|
||||
test('removes mouse reports without dropping surrounding text', () => {
|
||||
const value = 'before\x1b[<65;88;20Mafter';
|
||||
assert.equal(containsMouseSequence(value), true);
|
||||
assert.equal(stripMouseSequences(value), 'beforeafter');
|
||||
assert.equal(stripMouseSequences('plain text'), 'plain text');
|
||||
});
|
||||
Reference in New Issue
Block a user