diff --git a/.ui-smoke.mjs b/.ui-smoke.mjs index 66e2564..3595e9f 100644 --- a/.ui-smoke.mjs +++ b/.ui-smoke.mjs @@ -49,7 +49,7 @@ 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('▲ 10 lines'), 'header shows scroll badge when scrolled'); assert.ok(!outScrolled.includes('filler line 30'), 'scrolled view hides the live tail'); assert.ok(outScrolled.includes('filler line 20'), 'scrolled view shows older lines'); scrolled.resolve(null); diff --git a/src/fido.js b/src/fido.js index 2e19449..0b16c58 100644 --- a/src/fido.js +++ b/src/fido.js @@ -38,7 +38,12 @@ import { printToolCall, emitGenericToolResult } from './tools/notify.js'; import { COMMANDS, REASONING_LEVELS, findCommand } from './slash-commands.js'; import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js'; import { mount, unmount, ui } from './ui/index.js'; -import { getBlockContentWidth, renderAssistantBlock, renderToolBlock } from './ui/blocks.js'; +import { + getBlockContentWidth, + renderAssistantBlock, + renderToolBlock, + renderUserBlock, +} from './ui/blocks.js'; // \u2500\u2500\u2500 Markdown \u2192 console renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 @@ -130,6 +135,88 @@ function toolErrorLine(name, error) { return renderToolBlock(chalk.red(`\u2716 Tool error [${name}]: ${error}`)); } +const RECALL_TOOL_LIMIT = 4000; + +/** Convert the provider's content variants into text suitable for a block. */ +function messageText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map((part) => { + if (typeof part === 'string') return part; + if (part && typeof part === 'object') return part.text ?? part.content ?? ''; + return ''; + }).filter(Boolean).join('\n'); + } + if (content == null) return ''; + if (typeof content === 'object') { + try { + return JSON.stringify(content) ?? String(content); + } catch { + return String(content); + } + } + return String(content); +} + +function clipRecallText(text, limit = RECALL_TOOL_LIMIT) { + const value = String(text ?? ''); + if (value.length <= limit) return value; + return value.slice(0, limit - 20) + '\n… [recalled message truncated] …'; +} + +/** Render one restored provider message in the same role blocks as live turns. */ +function recalledMessageBlock(message) { + if (!message || typeof message !== 'object') return null; + + const content = messageText(message.content); + if (message.role === 'user') { + if (!content.trim()) return null; + return renderUserBlock(`› You ${content.replace(/\n/g, '\n ')}`); + } + + if (message.role === 'assistant') { + if (content.trim()) return assistantBlock(content); + + const calls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + if (calls.length === 0) return null; + const lines = calls.map((call) => { + const fn = call?.function || {}; + let args = fn.arguments ?? ''; + if (typeof args !== 'string') { + try { + args = JSON.stringify(args); + } catch { + args = String(args); + } + } + args = String(args).replace(/\n/g, ' ').trim(); + if (args.length > 240) args = args.slice(0, 237) + '...'; + return `⚙ Tool: ${fn.name || 'unknown'}${args ? `(${args})` : ''}`; + }); + return renderToolBlock(chalk.dim(lines.join('\n'))); + } + + if (message.role === 'tool') { + if (!content.trim()) return null; + return renderToolBlock(chalk.dim(`↳ ${clipRecallText(content)}`)); + } + + return null; +} + +/** Add the restored conversation to the visible transcript. */ +function logRecalledMessages(restored) { + let shown = 0; + for (const message of restored) { + const block = recalledMessageBlock(message); + if (block) { + ui.log(block); + shown++; + } + } + return shown; +} + /** * Render a bash_exec result as a complete tool block: status, stdout/stderr, * and any truncation notes share the same teal background and amber rail. @@ -379,16 +466,22 @@ async function main() { continue; } const snap = suspends[choice]; + if (!snap) { + ui.log(chalk.yellow(' Recall cancelled.')); + continue; + } const restored = Array.isArray(snap.messages) ? snap.messages : []; + const restoredMessages = restored.filter((m) => m && m.role !== 'system'); // Keep the freshly built system prompt (workspace may have changed) // and restore the rest of the conversation verbatim. messages = [ { role: 'system', content: systemPrompt }, - ...restored.filter((m) => m.role !== 'system'), + ...restoredMessages, ]; const turns = messages.length - 1; ui.log(chalk.green(` \u25b6 Recalled: ${snap.description}`)); - ui.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored.`)); + ui.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored; showing transcript below.`)); + logRecalledMessages(restoredMessages); continue; } diff --git a/src/ui/app.js b/src/ui/app.js index b7bf060..962bec4 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -29,10 +29,20 @@ import { createElement as h, useEffect, + useLayoutEffect, + useRef, useState, useSyncExternalStore, } from 'react'; -import { Box, Text, useApp, useInput, useWindowSize } from 'ink'; +import { + Box, + Text, + measureElement, + useApp, + useBoxMetrics, + useInput, + useWindowSize, +} from 'ink'; import chalk from 'chalk'; import { MainInput } from './main-input.js'; @@ -171,11 +181,31 @@ export function getHistoryWindow(entries, scroll = 0, maxLines = MAX_VISIBLE_LIN } function History({ entries, scroll }) { - const { lines } = getHistoryWindow(entries, scroll, MAX_VISIBLE_LINES); + // Yoga clips an oversized child from its top edge, even when the parent is + // bottom-anchored. Mount no more lines than the history pane can actually + // display; otherwise an overflowing 120-line window hides the live tail. + const ref = useRef(null); + const metrics = useBoxMetrics(ref); + const [layoutHeight, setLayoutHeight] = useState(0); + + // useBoxMetrics updates when a sibling (for example, the file browser) + // changes the command-bar height. A layout effect also gives synchronous + // renderToString output a correctly sized initial window. + useLayoutEffect(() => { + const node = ref.current; + if (!node) return; + const height = measureElement(node).height; + setLayoutHeight((previous) => previous === height ? previous : height); + }); + + const height = metrics.hasMeasured ? metrics.height : layoutHeight; + const maxLines = Math.max(1, Math.trunc(Number(height)) || 1); + const { lines } = getHistoryWindow(entries, scroll, maxLines); const visibleText = lines.map((line) => line.text).join('\n'); return h( Box, { + ref, flexGrow: 1, flexShrink: 1, flexDirection: 'column', @@ -233,13 +263,23 @@ export function App({ store, sizeOverride }) { // external $EDITOR (for /paste) without corrupting the Ink frame. useEffect(() => { store._suspend = (fn) => suspendTerminal(fn); - setMouseTracking(true); return () => { - setMouseTracking(false); store._suspend = null; }; }, [store, suspendTerminal]); + // Mouse mode is terminal-global. Keep its lifecycle independent from the + // command-suspension effect so a re-render (or a prompt replacement) cannot + // accidentally leave tracking disabled. + useEffect(() => { + store._setMouseTracking = (on) => setMouseTracking(on); + setMouseTracking(true); + return () => { + store._setMouseTracking = null; + setMouseTracking(false); + }; + }, [store]); + // Global keys. Prompt components own arrows/Tab/Enter; the text input // swallows printable chars and ignores PageUp/PageDown, so history scrolling // here never fights them. diff --git a/src/ui/mouse.js b/src/ui/mouse.js index 05cd46f..e2fa7b1 100644 --- a/src/ui/mouse.js +++ b/src/ui/mouse.js @@ -12,6 +12,16 @@ const MOUSE_SEQUENCE_RE = /\x1b?\[<(\d+);\d+;\d+[Mm]/g; const COMPLETE_MOUSE_SEQUENCE_RE = /^\x1b?\[<(\d+);\d+;\d+[Mm]$/; +// Some terminals fall back to the original X10 encoding even after SGR +// tracking is requested: ESC [ M