mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73660729ab | ||
|
|
701dbcc32b | ||
|
|
1603479a3d | ||
|
|
d263068bb2 | ||
|
|
83768b92e4 | ||
|
|
9174c1ba59 | ||
|
|
95c978722a | ||
|
|
37e741afa1 | ||
|
|
526e3edc97 | ||
|
|
60024a5223 | ||
|
|
e463fe7518 | ||
|
|
53ab16bab4 | ||
|
|
4b2f2bf183 | ||
|
|
9d775acb94 | ||
|
|
70bffed518 | ||
|
|
77285a251c | ||
|
|
7f7b48021e | ||
|
|
732593e4ed | ||
|
|
d4662df023 | ||
|
|
06f4716e17 | ||
|
|
49d4648b07 | ||
|
|
634f8e6028 | ||
|
|
9d09a14f91 | ||
|
|
1c023126d9 | ||
|
|
87d87c8f47 | ||
|
|
50c757d203 |
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.5",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Minimal Ctrl+C probe. Usage: node scripts/ctrlc-probe.mjs
|
||||
// Reproduces the exact stdin setup Reasonix uses, then logs every byte
|
||||
// it sees and exits on \x03. Tells us whether the OS even delivers
|
||||
// Ctrl+C to a Node child on this terminal.
|
||||
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.resume();
|
||||
|
||||
console.log("ctrl+c probe armed. press keys; ctrl+c should exit.");
|
||||
console.log("if ctrl+c does NOT exit, the byte never reaches Node on this terminal.\n");
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
const s = String(chunk);
|
||||
for (const ch of s) {
|
||||
const code = ch.charCodeAt(0);
|
||||
process.stdout.write(`got byte: 0x${code.toString(16).padStart(2, "0")} (${JSON.stringify(ch)})\r\n`);
|
||||
if (ch === "\x03") {
|
||||
process.stdout.write("→ \\x03 detected, exiting.\r\n");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
process.stdout.write("→ SIGINT received, exiting.\r\n");
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Shift+Enter probe. Usage: node scripts/shift-enter-probe.mjs
|
||||
// Enables modifyOtherKeys + kitty keyboard protocol, then prints the
|
||||
// raw bytes for every keypress. Press Shift+Enter and see what your
|
||||
// terminal actually emits — if it's just "0x0d", the host doesn't
|
||||
// support either protocol and there's nothing Reasonix can do at the
|
||||
// raw-stdin layer.
|
||||
|
||||
process.stdout.write("\u001b[>4;2m"); // modifyOtherKeys level 2 (xterm)
|
||||
process.stdout.write("\u001b[>1u"); // kitty keyboard protocol level 1
|
||||
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.resume();
|
||||
|
||||
console.log("shift+enter probe armed. press keys; ctrl+c exits.");
|
||||
console.log("expected for shift+enter:");
|
||||
console.log(" modifyOtherKeys → 0x1b 0x5b 0x32 0x37 0x3b 0x32 0x3b 0x31 0x33 0x7e (\\x1b[27;2;13~)");
|
||||
console.log(" kitty → 0x1b 0x5b 0x31 0x33 0x3b 0x32 0x75 (\\x1b[13;2u)");
|
||||
console.log(" unsupported → 0x0d (just \\r — no way to distinguish)\n");
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
const s = String(chunk);
|
||||
const bytes = [...s].map((c) => `0x${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join(" ");
|
||||
process.stdout.write(`got: ${bytes} (${JSON.stringify(s)})\r\n`);
|
||||
if (s.includes("\x03")) {
|
||||
process.stdout.write("\u001b[>4m"); // disable modifyOtherKeys
|
||||
process.stdout.write("\u001b[<u"); // pop kitty level
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
+53
-27
@@ -21,6 +21,7 @@ import { registerWebTools } from "../../tools/web.js";
|
||||
import { App } from "../ui/App.js";
|
||||
import { SessionPicker } from "../ui/SessionPicker.js";
|
||||
import { Setup } from "../ui/Setup.js";
|
||||
import { KeystrokeProvider } from "../ui/keystroke-context.js";
|
||||
import type { McpServerSummary } from "../ui/slash.js";
|
||||
|
||||
export interface ProgressInfo {
|
||||
@@ -102,39 +103,49 @@ function Root({
|
||||
}
|
||||
process.env.DEEPSEEK_API_KEY = key;
|
||||
|
||||
// KeystrokeProvider must wrap App from OUTSIDE — App.tsx itself
|
||||
// calls `useKeystroke` in its function body for global hotkeys
|
||||
// (Ctrl+C, Esc abort, Shift+Tab edit-mode cycle, @-mention picker,
|
||||
// slash-suggestion navigation). If the provider lives inside App's
|
||||
// render, `useContext(KeystrokeContext)` returns `null` at hook
|
||||
// call time and those handlers silently never subscribe.
|
||||
if (pending && appProps.session) {
|
||||
return (
|
||||
<SessionPicker
|
||||
sessionName={appProps.session}
|
||||
messageCount={pending.messageCount}
|
||||
lastActive={pending.lastActive}
|
||||
onChoose={(choice) => {
|
||||
if (choice === "new" || choice === "delete") {
|
||||
// Wipe the session file. "new" and "delete" do the same thing
|
||||
// at this step — the distinction is only in the picker's
|
||||
// wording. A future enhancement could archive on "new".
|
||||
rewriteSession(appProps.session!, []);
|
||||
}
|
||||
setPending(undefined);
|
||||
}}
|
||||
/>
|
||||
<KeystrokeProvider>
|
||||
<SessionPicker
|
||||
sessionName={appProps.session}
|
||||
messageCount={pending.messageCount}
|
||||
lastActive={pending.lastActive}
|
||||
onChoose={(choice) => {
|
||||
if (choice === "new" || choice === "delete") {
|
||||
// Wipe the session file. "new" and "delete" do the same thing
|
||||
// at this step — the distinction is only in the picker's
|
||||
// wording. A future enhancement could archive on "new".
|
||||
rewriteSession(appProps.session!, []);
|
||||
}
|
||||
setPending(undefined);
|
||||
}}
|
||||
/>
|
||||
</KeystrokeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<App
|
||||
model={appProps.model}
|
||||
system={appProps.system}
|
||||
transcript={appProps.transcript}
|
||||
harvest={appProps.harvest}
|
||||
branch={appProps.branch}
|
||||
session={appProps.session}
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
progressSink={progressSink}
|
||||
codeMode={appProps.codeMode}
|
||||
/>
|
||||
<KeystrokeProvider>
|
||||
<App
|
||||
model={appProps.model}
|
||||
system={appProps.system}
|
||||
transcript={appProps.transcript}
|
||||
harvest={appProps.harvest}
|
||||
branch={appProps.branch}
|
||||
session={appProps.session}
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
progressSink={progressSink}
|
||||
codeMode={appProps.codeMode}
|
||||
/>
|
||||
</KeystrokeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,6 +290,21 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
rewriteSession(opts.session, []);
|
||||
}
|
||||
|
||||
// Clear the terminal viewport AND scrollback before mounting Ink
|
||||
// so the user lands on a fresh canvas — no leftover prompt /
|
||||
// last-command output bleeding into the TUI's first frame.
|
||||
// \x1b[2J — erase visible screen
|
||||
// \x1b[3J — erase scrollback (xterm extension; safely ignored
|
||||
// by terminals that don't implement it)
|
||||
// \x1b[H — cursor home
|
||||
// Stdout-only — stderr stays untouched so any startup diagnostics
|
||||
// we wrote earlier (loadDotenv, MCP connect logs) aren't lost.
|
||||
// Skipped on non-TTY runs (CI, piped output) where ANSI sequences
|
||||
// would leak into the captured output.
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
||||
}
|
||||
|
||||
const { waitUntilExit } = render(
|
||||
<Root
|
||||
initialKey={initialKey}
|
||||
|
||||
@@ -110,15 +110,16 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
|
||||
// Belt-and-suspenders cleanup: even though spawn(detached:false)
|
||||
// should tie child processes to the parent's lifetime, Windows cmd.exe
|
||||
// wrappers occasionally leak. Kill everything on both clean and
|
||||
// signal-driven exits. 'exit' fires too late for async I/O but is our
|
||||
// only guaranteed last word; the signal handlers do the heavy lifting.
|
||||
const sigShutdown = () => {
|
||||
// wrappers occasionally leak. We DON'T install SIGINT/SIGTERM
|
||||
// handlers here — that overrode Node's default "exit on Ctrl+C" with
|
||||
// a silent no-op, which made Ctrl+C feel broken in the TUI. App.tsx
|
||||
// owns the SIGINT path now (it shows the quit-armed banner and calls
|
||||
// exit() on confirmation); this 'exit' hook just guarantees the job
|
||||
// registry is drained on the way out, regardless of which exit path
|
||||
// fired.
|
||||
process.once("exit", () => {
|
||||
void jobs.shutdown();
|
||||
};
|
||||
process.once("SIGINT", sigShutdown);
|
||||
process.once("SIGTERM", sigShutdown);
|
||||
process.once("exit", sigShutdown);
|
||||
});
|
||||
|
||||
await chatCommand({
|
||||
model: opts.model ?? "deepseek-v4-flash",
|
||||
|
||||
+283
-221
@@ -1,5 +1,5 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { Box, Static, useApp, useInput, useStdout } from "ink";
|
||||
import { Box, Static, useApp, useStdout } from "ink";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { expandAtMentions } from "../../at-mentions.js";
|
||||
import {
|
||||
@@ -55,8 +55,10 @@ import { ShellConfirm, type ShellConfirmChoice, derivePrefix } from "./ShellConf
|
||||
import { SlashArgPicker } from "./SlashArgPicker.js";
|
||||
import { SlashSuggestions } from "./SlashSuggestions.js";
|
||||
import { StatsPanel } from "./StatsPanel.js";
|
||||
import { WelcomeBanner } from "./WelcomeBanner.js";
|
||||
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
|
||||
import { describeRepair, formatEditResults, formatPendingPreview } from "./edit-history.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { handleMcpBrowseSlash } from "./mcp-browse.js";
|
||||
import { formatLongPaste } from "./paste-collapse.js";
|
||||
import { type McpServerSummary, handleSlash, parseSlash, suggestSlashCommands } from "./slash.js";
|
||||
@@ -173,20 +175,28 @@ export function App({
|
||||
// them. Writing CSI 2J + 3J + H genuinely nukes viewport AND
|
||||
// scrollback, which is what `/clear` means to a shell user.
|
||||
const { stdout } = useStdout();
|
||||
// Bracketed paste mode (DECSET 2004): tell the terminal to wrap
|
||||
// pasted text with \x1b[200~ … \x1b[201~ markers. Without this,
|
||||
// a multi-chunk paste from stdin can interleave with key events
|
||||
// (Ink sometimes interprets the paste's trailing \n as Enter,
|
||||
// submitting the partial buffer). With it, the terminal hands
|
||||
// us the paste as one atomic payload that our processMultilineKey
|
||||
// can detect via the markers and insert in one shot.
|
||||
// Disable on unmount so the user's shell doesn't inherit the
|
||||
// mode after the TUI exits.
|
||||
// Terminal input modes we opt into at startup, all paired with
|
||||
// disable-on-unmount so the user's shell doesn't inherit them:
|
||||
//
|
||||
// • Bracketed paste (DECSET 2004) — terminal wraps pasted text
|
||||
// with \x1b[200~ … \x1b[201~ markers so a multi-chunk paste
|
||||
// can't be misread as keystrokes (the trailing \n in a paste
|
||||
// would otherwise fire submit).
|
||||
//
|
||||
// • modifyOtherKeys level 2 (CSI > 4 ; 2 m) — terminal encodes
|
||||
// modifier-bearing keypresses (Shift+Enter, Ctrl+Enter, etc.)
|
||||
// as `\x1b[27;<mod>;<key>~` instead of the bare ASCII byte. Our
|
||||
// stdin-reader recognises `27;2;13~` as Shift+Enter and
|
||||
// `27;5;13~` as Ctrl+Enter. Terminals that don't understand the
|
||||
// SGR fall through silently — Shift+Enter just stays
|
||||
// indistinguishable from Enter, no regression.
|
||||
useEffect(() => {
|
||||
if (!stdout || !stdout.isTTY) return;
|
||||
stdout.write("\u001b[?2004h");
|
||||
stdout.write("\u001b[>4;2m");
|
||||
return () => {
|
||||
stdout.write("\u001b[?2004l");
|
||||
stdout.write("\u001b[>4m");
|
||||
};
|
||||
}, [stdout]);
|
||||
// Subagent UI wiring: live activity row + sink ref the loop closure
|
||||
@@ -698,6 +708,25 @@ export function App({
|
||||
}
|
||||
}, [session, loop, codeMode, syncPendingCount]);
|
||||
|
||||
// Ctrl+C exits, period. SIGINT (cooked-mode terminals + Node's
|
||||
// Windows console handler) and \x03 byte (raw-mode stdin) both
|
||||
// converge on `quitProcess`. We call `process.exit` directly rather
|
||||
// than Ink's `exit()` because the singleton stdin-reader keeps a
|
||||
// `data` listener attached — `exit()` would unmount the React tree
|
||||
// but the event loop would stay alive and the terminal would hang.
|
||||
// Esc handles "abort the current turn" separately; Ctrl+C is the
|
||||
// universal "I'm done" key, no banner, no double-press dance.
|
||||
const quitProcess = useCallback(() => {
|
||||
transcriptRef.current?.end();
|
||||
process.exit(0);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
process.on("SIGINT", quitProcess);
|
||||
return () => {
|
||||
process.off("SIGINT", quitProcess);
|
||||
};
|
||||
}, [quitProcess]);
|
||||
|
||||
// Esc during busy → forward to the loop as an abort signal. The loop
|
||||
// finishes the tool call in flight (we can't kill subprocess stdio
|
||||
// mid-write), then diverts to its no-tools summary path so the user
|
||||
@@ -707,7 +736,25 @@ export function App({
|
||||
// Also handles ↑/↓ shell-style history while idle. We don't use
|
||||
// ink-text-input's (absent) history support; parent-level useInput
|
||||
// is simpler and lets us own the cursor semantics.
|
||||
useInput((chKey, key) => {
|
||||
useKeystroke((ev) => {
|
||||
// PromptInput consumes its own keystrokes via useKeystroke too,
|
||||
// so events fan out to both this handler and PromptInput's. The
|
||||
// global hotkeys here only fire when the relevant condition
|
||||
// (busy / codeMode / etc.) holds, otherwise they no-op and let
|
||||
// PromptInput own the key.
|
||||
const chKey = ev.input;
|
||||
const key = ev;
|
||||
if (ev.paste) {
|
||||
// Paste content goes only to PromptInput. Don't run global
|
||||
// hotkey logic over it (a `\n` in paste shouldn't fire submit).
|
||||
return;
|
||||
}
|
||||
// Ctrl+C → exit. Always. Same target as the SIGINT path above —
|
||||
// whichever route delivers Ctrl+C on the user's terminal wins.
|
||||
if (key.ctrl && key.input === "c") {
|
||||
quitProcess();
|
||||
return;
|
||||
}
|
||||
if (key.escape && busy) {
|
||||
if (abortedThisTurn.current) return;
|
||||
abortedThisTurn.current = true;
|
||||
@@ -2501,103 +2548,117 @@ export function App({
|
||||
[],
|
||||
);
|
||||
|
||||
// KeystrokeProvider is mounted by chat.tsx OUTSIDE this component
|
||||
// so `useKeystroke` calls in App's function body see the bus.
|
||||
return (
|
||||
<TickerProvider
|
||||
disabled={
|
||||
PLAIN_UI ||
|
||||
!!pendingPlan ||
|
||||
!!pendingShell ||
|
||||
!!pendingEditReview ||
|
||||
!!pendingCheckpoint ||
|
||||
!!stagedCheckpointRevise ||
|
||||
!!pendingChoice ||
|
||||
!!stagedChoiceCustom ||
|
||||
!!pendingRevision
|
||||
}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<StatsPanel
|
||||
summary={summary}
|
||||
model={loop.model}
|
||||
prefixHash={prefixHash}
|
||||
harvestOn={loop.harvestEnabled}
|
||||
branchBudget={loop.branchOptions.budget}
|
||||
reasoningEffort={loop.reasoningEffort}
|
||||
planMode={planMode}
|
||||
editMode={codeMode ? editMode : undefined}
|
||||
balance={balance}
|
||||
busy={busy}
|
||||
updateAvailable={updateAvailable}
|
||||
proArmed={proArmed}
|
||||
escalated={turnOnPro}
|
||||
/>
|
||||
<Static items={historical}>
|
||||
{(item) => <EventRow key={item.id} event={item} projectRoot={hookCwd} />}
|
||||
</Static>
|
||||
{/*
|
||||
<>
|
||||
<TickerProvider
|
||||
disabled={
|
||||
PLAIN_UI ||
|
||||
!!pendingPlan ||
|
||||
!!pendingShell ||
|
||||
!!pendingEditReview ||
|
||||
!!pendingCheckpoint ||
|
||||
!!stagedCheckpointRevise ||
|
||||
!!pendingChoice ||
|
||||
!!stagedChoiceCustom ||
|
||||
!!pendingRevision
|
||||
}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<StatsPanel
|
||||
summary={summary}
|
||||
model={loop.model}
|
||||
prefixHash={prefixHash}
|
||||
harvestOn={loop.harvestEnabled}
|
||||
branchBudget={loop.branchOptions.budget}
|
||||
reasoningEffort={loop.reasoningEffort}
|
||||
planMode={planMode}
|
||||
editMode={codeMode ? editMode : undefined}
|
||||
balance={balance}
|
||||
busy={busy}
|
||||
updateAvailable={updateAvailable}
|
||||
proArmed={proArmed}
|
||||
escalated={turnOnPro}
|
||||
/>
|
||||
<Static items={historical}>
|
||||
{(item) => <EventRow key={item.id} event={item} projectRoot={hookCwd} />}
|
||||
</Static>
|
||||
{/*
|
||||
Welcome card on the empty state. Visible only when nothing
|
||||
has happened yet (no past events, nothing in flight, no
|
||||
modal up). Removes the "what do I type?" friction without
|
||||
surviving past the first turn.
|
||||
*/}
|
||||
{!historical.some((e) => e.role === "user" || e.role === "assistant") &&
|
||||
!busy &&
|
||||
!streaming ? (
|
||||
<WelcomeBanner inCodeMode={!!codeMode} />
|
||||
) : null}
|
||||
{/*
|
||||
Live rows are hidden while the ShellConfirm modal is up — the
|
||||
model's concurrent "please confirm" stream is noise the user
|
||||
doesn't need, and the picker shouldn't fight it for visual
|
||||
attention. They come back naturally once the user chooses and
|
||||
the next turn begins.
|
||||
*/}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
streaming ? (
|
||||
<Box marginY={1}>
|
||||
<EventRow event={streaming} projectRoot={hookCwd} />
|
||||
</Box>
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
ongoingTool ? (
|
||||
<OngoingToolRow tool={ongoingTool} progress={toolProgress} />
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
subagentActivity ? (
|
||||
<SubagentRow activity={subagentActivity} />
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
!ongoingTool &&
|
||||
statusLine ? (
|
||||
<StatusRow text={statusLine} />
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
undoBanner &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
!pendingChoice &&
|
||||
!stagedChoiceCustom &&
|
||||
!pendingRevision ? (
|
||||
<UndoBanner banner={undoBanner} />
|
||||
) : null}
|
||||
{/*
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
streaming ? (
|
||||
<Box marginY={1}>
|
||||
<EventRow event={streaming} projectRoot={hookCwd} />
|
||||
</Box>
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
ongoingTool ? (
|
||||
<OngoingToolRow tool={ongoingTool} progress={toolProgress} />
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
subagentActivity ? (
|
||||
<SubagentRow activity={subagentActivity} />
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
!ongoingTool &&
|
||||
statusLine ? (
|
||||
<StatusRow text={statusLine} />
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
undoBanner &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
!pendingChoice &&
|
||||
!stagedChoiceCustom &&
|
||||
!pendingRevision ? (
|
||||
<UndoBanner banner={undoBanner} />
|
||||
) : null}
|
||||
{/*
|
||||
Belt-and-suspenders fallback: if we're busy but NONE of the
|
||||
specific indicators (streaming, ongoingTool, statusLine) is
|
||||
visible, something is still happening — show a generic
|
||||
@@ -2605,128 +2666,129 @@ export function App({
|
||||
without a label. Catches micro-gaps between events that the
|
||||
targeted status lines don't cover.
|
||||
*/}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
busy &&
|
||||
!streaming &&
|
||||
!ongoingTool &&
|
||||
!statusLine ? (
|
||||
<StatusRow text="processing…" />
|
||||
) : null}
|
||||
{stagedInput ? (
|
||||
<PlanRefineInput
|
||||
mode={stagedInput.mode}
|
||||
onSubmit={handleStagedInputSubmit}
|
||||
onCancel={handleStagedInputCancel}
|
||||
/>
|
||||
) : stagedCheckpointRevise ? (
|
||||
<PlanRefineInput
|
||||
mode="checkpoint-revise"
|
||||
onSubmit={handleCheckpointReviseSubmit}
|
||||
onCancel={handleCheckpointReviseCancel}
|
||||
/>
|
||||
) : stagedChoiceCustom ? (
|
||||
<PlanRefineInput
|
||||
mode="choice-custom"
|
||||
onSubmit={handleChoiceCustomSubmit}
|
||||
onCancel={handleChoiceCustomCancel}
|
||||
/>
|
||||
) : pendingChoice ? (
|
||||
<ChoiceConfirm
|
||||
question={pendingChoice.question}
|
||||
options={pendingChoice.options}
|
||||
allowCustom={pendingChoice.allowCustom}
|
||||
onChoose={stableHandleChoiceConfirm}
|
||||
/>
|
||||
) : pendingRevision ? (
|
||||
<PlanReviseConfirm
|
||||
reason={pendingRevision.reason}
|
||||
oldRemaining={(planStepsRef.current ?? []).filter(
|
||||
(s) => !completedStepIdsRef.current.has(s.id),
|
||||
)}
|
||||
newRemaining={pendingRevision.remainingSteps}
|
||||
summary={pendingRevision.summary}
|
||||
onChoose={stableHandleReviseConfirm}
|
||||
/>
|
||||
) : pendingCheckpoint ? (
|
||||
<PlanCheckpointConfirm
|
||||
stepId={pendingCheckpoint.stepId}
|
||||
title={pendingCheckpoint.title}
|
||||
completed={pendingCheckpoint.completed}
|
||||
total={pendingCheckpoint.total}
|
||||
steps={planStepsRef.current ?? undefined}
|
||||
completedStepIds={completedStepIdsRef.current}
|
||||
onChoose={stableHandleCheckpointConfirm}
|
||||
/>
|
||||
) : pendingPlan ? (
|
||||
<PlanConfirm
|
||||
plan={pendingPlan}
|
||||
steps={planStepsRef.current ?? undefined}
|
||||
summary={planSummaryRef.current ?? undefined}
|
||||
onChoose={stableHandlePlanConfirm}
|
||||
projectRoot={hookCwd}
|
||||
/>
|
||||
) : pendingShell ? (
|
||||
<ShellConfirm
|
||||
command={pendingShell.command}
|
||||
allowPrefix={derivePrefix(pendingShell.command)}
|
||||
kind={pendingShell.kind}
|
||||
onChoose={handleShellConfirm}
|
||||
/>
|
||||
) : pendingEditReview ? (
|
||||
<EditConfirm
|
||||
block={pendingEditReview}
|
||||
onChoose={(choice) => {
|
||||
const resolve = editReviewResolveRef.current;
|
||||
if (resolve) {
|
||||
editReviewResolveRef.current = null;
|
||||
resolve(choice);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{codeMode ? (
|
||||
<ModeStatusBar
|
||||
editMode={editMode}
|
||||
pendingCount={pendingCount}
|
||||
flash={modeFlash}
|
||||
planMode={planMode}
|
||||
undoArmed={!!undoBanner || hasUndoable()}
|
||||
jobs={codeMode.jobs}
|
||||
/>
|
||||
) : null}
|
||||
<PromptInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
disabled={busy}
|
||||
onHistoryPrev={recallPrev}
|
||||
onHistoryNext={recallNext}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!pendingCheckpoint &&
|
||||
!stagedCheckpointRevise &&
|
||||
busy &&
|
||||
!streaming &&
|
||||
!ongoingTool &&
|
||||
!statusLine ? (
|
||||
<StatusRow text="processing…" />
|
||||
) : null}
|
||||
{stagedInput ? (
|
||||
<PlanRefineInput
|
||||
mode={stagedInput.mode}
|
||||
onSubmit={handleStagedInputSubmit}
|
||||
onCancel={handleStagedInputCancel}
|
||||
/>
|
||||
<SlashSuggestions matches={slashMatches} selectedIndex={slashSelected} />
|
||||
<AtMentionSuggestions
|
||||
matches={atMatches}
|
||||
selectedIndex={atSelected}
|
||||
query={atPicker?.query ?? ""}
|
||||
) : stagedCheckpointRevise ? (
|
||||
<PlanRefineInput
|
||||
mode="checkpoint-revise"
|
||||
onSubmit={handleCheckpointReviseSubmit}
|
||||
onCancel={handleCheckpointReviseCancel}
|
||||
/>
|
||||
{slashArgContext ? (
|
||||
<SlashArgPicker
|
||||
matches={slashArgMatches}
|
||||
selectedIndex={slashArgSelected}
|
||||
spec={slashArgContext.spec}
|
||||
kind={slashArgContext.kind}
|
||||
partial={slashArgContext.partial}
|
||||
) : stagedChoiceCustom ? (
|
||||
<PlanRefineInput
|
||||
mode="choice-custom"
|
||||
onSubmit={handleChoiceCustomSubmit}
|
||||
onCancel={handleChoiceCustomCancel}
|
||||
/>
|
||||
) : pendingChoice ? (
|
||||
<ChoiceConfirm
|
||||
question={pendingChoice.question}
|
||||
options={pendingChoice.options}
|
||||
allowCustom={pendingChoice.allowCustom}
|
||||
onChoose={stableHandleChoiceConfirm}
|
||||
/>
|
||||
) : pendingRevision ? (
|
||||
<PlanReviseConfirm
|
||||
reason={pendingRevision.reason}
|
||||
oldRemaining={(planStepsRef.current ?? []).filter(
|
||||
(s) => !completedStepIdsRef.current.has(s.id),
|
||||
)}
|
||||
newRemaining={pendingRevision.remainingSteps}
|
||||
summary={pendingRevision.summary}
|
||||
onChoose={stableHandleReviseConfirm}
|
||||
/>
|
||||
) : pendingCheckpoint ? (
|
||||
<PlanCheckpointConfirm
|
||||
stepId={pendingCheckpoint.stepId}
|
||||
title={pendingCheckpoint.title}
|
||||
completed={pendingCheckpoint.completed}
|
||||
total={pendingCheckpoint.total}
|
||||
steps={planStepsRef.current ?? undefined}
|
||||
completedStepIds={completedStepIdsRef.current}
|
||||
onChoose={stableHandleCheckpointConfirm}
|
||||
/>
|
||||
) : pendingPlan ? (
|
||||
<PlanConfirm
|
||||
plan={pendingPlan}
|
||||
steps={planStepsRef.current ?? undefined}
|
||||
summary={planSummaryRef.current ?? undefined}
|
||||
onChoose={stableHandlePlanConfirm}
|
||||
projectRoot={hookCwd}
|
||||
/>
|
||||
) : pendingShell ? (
|
||||
<ShellConfirm
|
||||
command={pendingShell.command}
|
||||
allowPrefix={derivePrefix(pendingShell.command)}
|
||||
kind={pendingShell.kind}
|
||||
onChoose={handleShellConfirm}
|
||||
/>
|
||||
) : pendingEditReview ? (
|
||||
<EditConfirm
|
||||
block={pendingEditReview}
|
||||
onChoose={(choice) => {
|
||||
const resolve = editReviewResolveRef.current;
|
||||
if (resolve) {
|
||||
editReviewResolveRef.current = null;
|
||||
resolve(choice);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{codeMode ? (
|
||||
<ModeStatusBar
|
||||
editMode={editMode}
|
||||
pendingCount={pendingCount}
|
||||
flash={modeFlash}
|
||||
planMode={planMode}
|
||||
undoArmed={!!undoBanner || hasUndoable()}
|
||||
jobs={codeMode.jobs}
|
||||
/>
|
||||
) : null}
|
||||
<PromptInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
disabled={busy}
|
||||
onHistoryPrev={recallPrev}
|
||||
onHistoryNext={recallNext}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</TickerProvider>
|
||||
<SlashSuggestions matches={slashMatches} selectedIndex={slashSelected} />
|
||||
<AtMentionSuggestions
|
||||
matches={atMatches}
|
||||
selectedIndex={atSelected}
|
||||
query={atPicker?.query ?? ""}
|
||||
/>
|
||||
{slashArgContext ? (
|
||||
<SlashArgPicker
|
||||
matches={slashArgMatches}
|
||||
selectedIndex={slashArgSelected}
|
||||
spec={slashArgContext.spec}
|
||||
kind={slashArgContext.kind}
|
||||
partial={slashArgContext.partial}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</TickerProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,6 @@ export function AtMentionSuggestions({
|
||||
}
|
||||
|
||||
function FileRow({ path, isSelected }: { path: string; isSelected: boolean }) {
|
||||
const marker = isSelected ? "▸" : " ";
|
||||
// Split the path so the basename visually pops — same dropdown
|
||||
// affordance as VS Code's Quick Open.
|
||||
const slash = path.lastIndexOf("/");
|
||||
@@ -68,19 +67,16 @@ function FileRow({ path, isSelected }: { path: string; isSelected: boolean }) {
|
||||
if (isSelected) {
|
||||
return (
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
{marker} {base}
|
||||
<Text backgroundColor="#67e8f9" color="black" bold>
|
||||
{` ▸ ${base}${dir ? ` ${dir}` : ""} `}
|
||||
</Text>
|
||||
<Text color="cyan">{dir ? ` ${dir}` : ""}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{marker} {base}
|
||||
{dir ? ` ${dir}` : ""}
|
||||
</Text>
|
||||
<Text color="#94a3b8">{` ${base}`}</Text>
|
||||
{dir ? <Text dimColor>{` ${dir}`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,10 @@
|
||||
* without reading headers.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import { Box } from "ink";
|
||||
import React from "react";
|
||||
import type { ChoiceOption } from "../../tools/choice.js";
|
||||
import { ModalCard } from "./ModalCard.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
|
||||
export type ChoiceConfirmChoice =
|
||||
@@ -53,16 +54,8 @@ function ChoiceConfirmInner({ question, options, allowCustom, onChoose }: Choice
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} marginY={1}>
|
||||
<ModalCard accent="#f0abfc" icon="🔀" title="model wants you to pick" subtitle={question}>
|
||||
<Box>
|
||||
<Text bold color="magenta">
|
||||
🔀 the model is asking you to pick
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>{question}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<SingleSelect
|
||||
initialValue={options[0]?.id}
|
||||
items={items}
|
||||
@@ -75,7 +68,7 @@ function ChoiceConfirmInner({ question, options, allowCustom, onChoose }: Choice
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] cancel"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</ModalCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+28
-34
@@ -1,7 +1,9 @@
|
||||
import { Box, Text, useInput, useStdout } from "ink";
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { formatEditBlockDiff } from "../../code/diff-preview.js";
|
||||
import type { EditBlock } from "../../code/edit-blocks.js";
|
||||
import { ModalCard } from "./ModalCard.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
|
||||
/**
|
||||
* Choice surfaced to the interceptor when the user resolves the modal:
|
||||
@@ -73,7 +75,10 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
|
||||
// keypress drove the offset past the new ceiling.
|
||||
const effectiveScroll = Math.min(scroll, maxScroll);
|
||||
|
||||
useInput((input, key) => {
|
||||
useKeystroke((ev) => {
|
||||
if (ev.paste) return;
|
||||
const input = ev.input;
|
||||
const key = ev;
|
||||
// Action keys first — decision wins over scroll so a user who
|
||||
// hammered PgDn can still hit `y` at the end without a stray
|
||||
// extra scroll.
|
||||
@@ -133,42 +138,31 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
|
||||
const totalLines = allLines.length;
|
||||
const showScrollHud = hiddenAbove + hiddenBelow > 0;
|
||||
|
||||
const subtitleParts = [`-${removed} +${added} lines`];
|
||||
if (showScrollHud) {
|
||||
subtitleParts.push(
|
||||
`viewing ${effectiveScroll + 1}-${effectiveScroll + visibleLines.length}/${totalLines}`,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="yellow">
|
||||
{"▸ model wants to edit a file"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="yellow" dimColor>
|
||||
{"──────────────────────────────────────────"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Text color={isNew ? "green" : "yellow"} bold>{`[${tag}] `}</Text>
|
||||
<Text color="cyan">{block.path}</Text>
|
||||
<Text dimColor>{` (-${removed} +${added} lines)`}</Text>
|
||||
{showScrollHud ? (
|
||||
<Text dimColor>
|
||||
{` · viewing ${effectiveScroll + 1}-${effectiveScroll + visibleLines.length}/${totalLines}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Box>
|
||||
<ModalCard
|
||||
accent={isNew ? "#86efac" : "#fcd34d"}
|
||||
icon={isNew ? "✚" : "✎"}
|
||||
title={`${tag} ${block.path}`}
|
||||
subtitle={subtitleParts.join(" · ")}
|
||||
>
|
||||
{hiddenAbove > 0 ? (
|
||||
<Text
|
||||
dimColor
|
||||
>{` ↑ ${hiddenAbove} line${hiddenAbove === 1 ? "" : "s"} above (↑/k or PgUp)`}</Text>
|
||||
) : null}
|
||||
<Box marginTop={hiddenAbove > 0 ? 0 : 1} flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
{visibleLines.map((line, i) => {
|
||||
const trimmed = line.trimStart();
|
||||
const color = trimmed.startsWith("+")
|
||||
? "green"
|
||||
? "#4ade80"
|
||||
: trimmed.startsWith("-")
|
||||
? "red"
|
||||
? "#f87171"
|
||||
: undefined;
|
||||
const dim = !color;
|
||||
return (
|
||||
@@ -193,28 +187,28 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{"["}
|
||||
<Text color="cyan" bold>
|
||||
<Text color="#67e8f9" bold>
|
||||
y
|
||||
</Text>
|
||||
{"/Enter] apply · ["}
|
||||
<Text color="cyan" bold>
|
||||
<Text color="#67e8f9" bold>
|
||||
n
|
||||
</Text>
|
||||
{"] reject · ["}
|
||||
<Text color="cyan" bold>
|
||||
<Text color="#67e8f9" bold>
|
||||
a
|
||||
</Text>
|
||||
{"] apply rest · ["}
|
||||
<Text color="cyan" bold>
|
||||
<Text color="#67e8f9" bold>
|
||||
A
|
||||
</Text>
|
||||
{"] flip AUTO · ["}
|
||||
<Text color="cyan" bold>
|
||||
<Text color="#67e8f9" bold>
|
||||
↑↓/Space
|
||||
</Text>
|
||||
{"] scroll · [Esc] abort"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</ModalCard>
|
||||
);
|
||||
}
|
||||
+307
-166
@@ -7,6 +7,7 @@ import type { PlanStep } from "../../tools/plan.js";
|
||||
import { PlanStateBlock } from "./PlanStateBlock.js";
|
||||
import { PlanStepList } from "./PlanStepList.js";
|
||||
import { Markdown } from "./markdown.js";
|
||||
import { COLOR, gradientCells } from "./theme.js";
|
||||
import { useElapsedSeconds, useTick } from "./ticker.js";
|
||||
import { formatDuration, summarizeToolResult } from "./tool-summary.js";
|
||||
|
||||
@@ -149,6 +150,23 @@ function RoleGlyph({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Solid-background pill for tool names. Mirrors the StatsPanel's mode
|
||||
* pills so the visual language stays consistent across the screen:
|
||||
* status (auto/review/plan), tool name, model. Status drives the
|
||||
* background color — yellow for ok/in-flight, red for errors — so a
|
||||
* scrollback full of tool dispatches reads at a glance.
|
||||
*/
|
||||
function ToolPill({ label, status }: { label: string; status: "ok" | "err" }) {
|
||||
const bg = status === "err" ? "red" : "yellow";
|
||||
const symbol = status === "err" ? "✗" : "✓";
|
||||
return (
|
||||
<Text backgroundColor={bg} color="black" bold>
|
||||
{` ${symbol} ${label} `}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad continuation lines so wrapped multi-line text aligns under the
|
||||
* glyph indent instead of jumping back to column 0. Header is `glyph
|
||||
@@ -169,12 +187,23 @@ export const EventRow = React.memo(function EventRow({
|
||||
}) {
|
||||
if (event.role === "user") {
|
||||
return (
|
||||
<Box marginTop={event.leadSeparator ? 1 : 0}>
|
||||
<RoleGlyph glyph={ROLE_GLYPH.user} color="cyan" />
|
||||
<Text>
|
||||
{" "}
|
||||
{indentContinuationLines(event.text)}
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
{event.leadSeparator ? <TurnSeparator /> : null}
|
||||
<Box>
|
||||
<RoleGlyph glyph={ROLE_GLYPH.user} color="cyan" />
|
||||
<Text>{" "}</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
borderColor={COLOR.user}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<Text>{indentContinuationLines(event.text)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -184,9 +213,25 @@ export const EventRow = React.memo(function EventRow({
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<RoleGlyph glyph={ROLE_GLYPH.assistant} color="green" />
|
||||
{event.stats ? <Text dimColor>{` ${event.stats.model}`}</Text> : null}
|
||||
{event.stats ? (
|
||||
<>
|
||||
<Text>{" "}</Text>
|
||||
<Text backgroundColor={COLOR.assistant} color="black" bold>
|
||||
{` ${event.stats.model.replace(/^deepseek-/, "")} `}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
borderColor={COLOR.assistant}
|
||||
paddingLeft={1}
|
||||
>
|
||||
{event.branch ? <BranchBlock branch={event.branch} /> : null}
|
||||
{event.reasoning ? <ReasoningBlock reasoning={event.reasoning} /> : null}
|
||||
{!isPlanStateEmpty(event.planState) ? (
|
||||
@@ -198,7 +243,7 @@ export const EventRow = React.memo(function EventRow({
|
||||
<Text dimColor>(no content)</Text>
|
||||
)}
|
||||
{event.stats ? <StatsLine stats={event.stats} /> : null}
|
||||
{event.repair ? <Text color="magenta">{event.repair}</Text> : null}
|
||||
{event.repair ? <Text color={COLOR.accent}>{event.repair}</Text> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -216,13 +261,19 @@ export const EventRow = React.memo(function EventRow({
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<RoleGlyph glyph={ROLE_GLYPH.toolOk} color="yellow" />
|
||||
<Text color="yellow" bold>{` ${event.toolName ?? "?"}`}</Text>
|
||||
<Text color="yellow" dimColor>
|
||||
{" →"}
|
||||
</Text>
|
||||
<ToolPill label={event.toolName ?? "?"} status="ok" />
|
||||
<Text dimColor>{" diff:"}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
borderColor={COLOR.tool}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<EditFileDiff text={event.text} />
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -233,21 +284,18 @@ export const EventRow = React.memo(function EventRow({
|
||||
// count for read_file, error tag for failures, ...). Full content
|
||||
// remains accessible via `/tool N`, hinted as a dim suffix.
|
||||
const summary = summarizeToolResult(event.toolName ?? "?", event.text);
|
||||
const color = summary.isError ? "red" : "yellow";
|
||||
const glyph = summary.isError ? ROLE_GLYPH.toolErr : ROLE_GLYPH.toolOk;
|
||||
const marker = summary.isError ? "✗" : "→";
|
||||
const status: "ok" | "err" = summary.isError ? "err" : "ok";
|
||||
const durationLabel =
|
||||
event.durationMs !== undefined && event.durationMs >= 100
|
||||
? ` (${formatDuration(event.durationMs)})`
|
||||
? formatDuration(event.durationMs)
|
||||
: "";
|
||||
const indexHint = event.toolIndex !== undefined ? ` /tool ${event.toolIndex}` : "";
|
||||
return (
|
||||
<Box>
|
||||
<RoleGlyph glyph={glyph} color={color} />
|
||||
<Text color={color} bold>{` ${event.toolName ?? "?"}`}</Text>
|
||||
{durationLabel ? <Text dimColor>{durationLabel}</Text> : null}
|
||||
<Text color={color} dimColor>{` ${marker} `}</Text>
|
||||
<Text color={summary.isError ? "red" : undefined} dimColor={!summary.isError}>
|
||||
<ToolPill label={event.toolName ?? "?"} status={status} />
|
||||
{durationLabel ? <Text dimColor>{` ${durationLabel}`}</Text> : null}
|
||||
<Text dimColor>{" "}</Text>
|
||||
<Text color={status === "err" ? "red" : undefined} dimColor={status === "ok"}>
|
||||
{summary.summary}
|
||||
</Text>
|
||||
{indexHint ? <Text dimColor>{indexHint}</Text> : null}
|
||||
@@ -257,24 +305,40 @@ export const EventRow = React.memo(function EventRow({
|
||||
if (event.role === "error") {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<RoleGlyph glyph={ROLE_GLYPH.error} color="red" />
|
||||
<Text color="red">
|
||||
{" "}
|
||||
{indentContinuationLines(event.text)}
|
||||
<Text backgroundColor="#f87171" color="black" bold>
|
||||
{" ✦ ERROR "}
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="#f87171">{indentContinuationLines(event.text)}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (event.role === "info") {
|
||||
// Detect tone from the text so the info row carries semantic
|
||||
// weight: ▲/⚠ → warn, ✓ → ok, ✗/✖ → error, ↻ → reload, otherwise
|
||||
// a neutral ▸. The leading glyph (if any) is consumed and the
|
||||
// body renders dim.
|
||||
const m = event.text.match(/^([▸▶▲⚠✓✗✖↻ⓘ])\s*(.*)$/s);
|
||||
const lead = m?.[1] ?? "▸";
|
||||
const body = m?.[2] ?? event.text;
|
||||
let leadColor: string = COLOR.info;
|
||||
if (lead === "▲" || lead === "⚠") leadColor = COLOR.warn;
|
||||
else if (lead === "✓") leadColor = COLOR.ok;
|
||||
else if (lead === "✗" || lead === "✖") leadColor = COLOR.err;
|
||||
else if (lead === "↻") leadColor = COLOR.primary;
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{event.text}</Text>
|
||||
<Text color={leadColor} bold>
|
||||
{lead}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>{body}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (event.role === "plan") {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginY={1}>
|
||||
<Box flexDirection="column" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
{"📋 plan proposed — pick a choice below"}
|
||||
@@ -288,16 +352,24 @@ export const EventRow = React.memo(function EventRow({
|
||||
}
|
||||
if (event.role === "step-progress") {
|
||||
const sp = event.stepProgress;
|
||||
const counter = sp && sp.total > 0 ? ` (${sp.completed}/${sp.total})` : "";
|
||||
const counter = sp && sp.total > 0 ? `${sp.completed}/${sp.total}` : "";
|
||||
const label = sp?.title ? `${sp.stepId} · ${sp.title}` : (sp?.stepId ?? "");
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color="green" bold>
|
||||
✓
|
||||
<Text backgroundColor="#4ade80" color="black" bold>
|
||||
{" ✓ STEP "}
|
||||
</Text>
|
||||
<Text color="green">{` ${label}`}</Text>
|
||||
<Text dimColor>{counter}</Text>
|
||||
{counter ? (
|
||||
<>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="#4ade80" bold>
|
||||
{counter}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
<Text>{" "}</Text>
|
||||
<Text color="#86efac">{label}</Text>
|
||||
</Box>
|
||||
{event.text ? (
|
||||
<Box paddingLeft={2}>
|
||||
@@ -306,7 +378,7 @@ export const EventRow = React.memo(function EventRow({
|
||||
) : null}
|
||||
{sp?.notes ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="yellow" dimColor>
|
||||
<Text color="#fbbf24" dimColor>
|
||||
{`note: ${sp.notes}`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -327,24 +399,25 @@ export const EventRow = React.memo(function EventRow({
|
||||
]),
|
||||
);
|
||||
// Focus the first pending step so the user immediately sees where
|
||||
// execution will resume from. If the plan is fully done, no focus
|
||||
// (the picker pattern uses › for "next up").
|
||||
// execution will resume from. If the plan is fully done, no focus.
|
||||
const nextStep = rp.steps.find((s) => !completedSet.has(s.id));
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginY={1}>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
▸ resumed plan
|
||||
</Text>
|
||||
<Text dimColor>{` ${done}/${total} done · last touched ${rp.relativeTime}`}</Text>
|
||||
</Box>
|
||||
{rp.summary ? (
|
||||
<Box>
|
||||
<Text color="cyan">{` ${rp.summary}`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box flexDirection="column" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text backgroundColor="#67e8f9" color="black" bold>
|
||||
{" ↻ RESUMED PLAN "}
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="#67e8f9" bold>
|
||||
{`${done}/${total}`}
|
||||
</Text>
|
||||
<Text dimColor>{` done · last touched ${rp.relativeTime}`}</Text>
|
||||
</Box>
|
||||
{rp.summary ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="#67e8f9">{rp.summary}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<PlanStepList steps={rp.steps} statuses={statuses} focusStepId={nextStep?.id} />
|
||||
</Box>
|
||||
@@ -360,26 +433,26 @@ export const EventRow = React.memo(function EventRow({
|
||||
const statuses = new Map(
|
||||
r.steps.map((s) => [s.id, completedSet.has(s.id) ? ("done" as const) : ("pending" as const)]),
|
||||
);
|
||||
const navHint = r.total > 1 ? ` · ${r.index}/${r.total}` : "";
|
||||
const navHint = r.total > 1 ? ` · ${r.index}/${r.total}` : "";
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} marginY={1}>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold dimColor>
|
||||
⏪ replay
|
||||
</Text>
|
||||
<Text
|
||||
dimColor
|
||||
>{` completed ${r.relativeTime} · ${done}/${total} done${navHint}`}</Text>
|
||||
</Box>
|
||||
{r.summary ? (
|
||||
<Box>
|
||||
<Text dimColor>{` ${r.summary}`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Text dimColor>{` ${r.archiveBasename}`}</Text>
|
||||
<Box flexDirection="column" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text backgroundColor="#94a3b8" color="black" bold>
|
||||
{" ⏪ REPLAY "}
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="#94a3b8" bold>
|
||||
{`${done}/${total}`}
|
||||
</Text>
|
||||
<Text dimColor>{` done · ${r.relativeTime}${navHint}`}</Text>
|
||||
</Box>
|
||||
{r.summary ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="#94a3b8">{r.summary}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Text dimColor>{r.archiveBasename}</Text>
|
||||
</Box>
|
||||
{r.body ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
@@ -402,11 +475,11 @@ export const EventRow = React.memo(function EventRow({
|
||||
if (event.role === "warning") {
|
||||
return (
|
||||
<Box>
|
||||
<RoleGlyph glyph={ROLE_GLYPH.warning} color="yellow" />
|
||||
<Text color="yellow">
|
||||
{" "}
|
||||
{indentContinuationLines(event.text)}
|
||||
<Text backgroundColor="#fbbf24" color="black" bold>
|
||||
{" ▲ WARN "}
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="#fbbf24">{indentContinuationLines(event.text)}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -418,27 +491,38 @@ export const EventRow = React.memo(function EventRow({
|
||||
});
|
||||
|
||||
/**
|
||||
* Thin horizontal rule between turns with a cyan ◆ centered as the
|
||||
* Reasonix mark. Past-turn separators live inside `<Static>` (Ink's
|
||||
* render-once optimization), so animation here would freeze at
|
||||
* tick=0 on every past turn — we keep it static by design. The
|
||||
* animated heartbeat lives in PulsingAssistantGlyph (current turn)
|
||||
* and the Wordmark (StatsPanel) where rerender is free.
|
||||
* Decorative gradient rule between turns. Uses the brand gradient
|
||||
* (teal → fuchsia) so the boundary between "old turn" and "new
|
||||
* turn" reads as a designed accent rather than a dim minus-sign
|
||||
* row. Static — past separators live inside Ink's `<Static>`, so
|
||||
* animation would freeze at tick=0 anyway.
|
||||
*/
|
||||
function TurnSeparator() {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const width = Math.max(16, cols - 2);
|
||||
const half = Math.floor((width - 3) / 2);
|
||||
const left = "─".repeat(half);
|
||||
const right = "─".repeat(width - half - 3);
|
||||
// Reserve 5 cells for ` ◆ ` in the middle. Split the remainder
|
||||
// into two gradient halves so the brand mark sits on the seam.
|
||||
const sideWidth = Math.max(2, Math.floor((width - 5) / 2));
|
||||
const leftCells = gradientCells(sideWidth, "─");
|
||||
const rightCells = gradientCells(sideWidth, "─");
|
||||
return (
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<Text dimColor>{left}</Text>
|
||||
<Text color="cyan" bold>
|
||||
{" ◆ "}
|
||||
{leftCells.map((c, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-width gradient row
|
||||
<Text key={`tsep-l-${i}`} color={c.color}>
|
||||
{c.ch}
|
||||
</Text>
|
||||
))}
|
||||
<Text color={COLOR.brand} bold>
|
||||
{" ◆ "}
|
||||
</Text>
|
||||
<Text dimColor>{right}</Text>
|
||||
{rightCells.map((c, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-width gradient row
|
||||
<Text key={`tsep-r-${i}`} color={c.color}>
|
||||
{c.ch}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -456,42 +540,57 @@ function EditFileDiff({ text }: { text: string }) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
// Line 0 is the status header ("edited X (A→B chars)"), Line 1 is
|
||||
// the unified-diff hunk header ("@@ -N,M +N,M @@"), the rest is
|
||||
// the colored diff body. We style the two headers distinctly so
|
||||
// the hunk marker stands out the way git-diff's cyan `@@` does in
|
||||
// most terminals.
|
||||
// the colored diff body. The hunk header gets a magenta-bg pill —
|
||||
// same idiom as the rest of the TUI — so it visually anchors each
|
||||
// hunk. Body lines render with a colored gutter glyph + tinted bg
|
||||
// for + and -, so the diff scans like a syntax-highlighted patch
|
||||
// file rather than a wall of monochrome text.
|
||||
const [statusHeader, hunkHeader, ...body] = lines;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>{` ${statusHeader ?? ""}`}</Text>
|
||||
{hunkHeader !== undefined ? (
|
||||
<Text color="cyan" bold>
|
||||
{hunkHeader}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text backgroundColor="#c4b5fd" color="black" bold>
|
||||
{` ${hunkHeader.trim()} `}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{body.map((line, i) => {
|
||||
// Key includes the line content slice so React isn't forced
|
||||
// to treat purely-positional identity; lines in the same
|
||||
// diff don't reorder but could repeat (e.g. blank lines).
|
||||
const key = `${i}-${line.slice(0, 32)}`;
|
||||
if (line.startsWith("- ")) {
|
||||
// Strip the leading " " indent diff-preview adds to every
|
||||
// body line so the gutter glyph is column 0 regardless of
|
||||
// formatter changes upstream.
|
||||
const stripped = line.replace(/^ {2}/, "");
|
||||
if (stripped.startsWith("- ")) {
|
||||
return (
|
||||
<Text key={key} color="red">
|
||||
{line}
|
||||
</Text>
|
||||
<Box key={key}>
|
||||
<Text color="#f87171" bold>
|
||||
{"− "}
|
||||
</Text>
|
||||
<Text color="#fca5a5">{stripped.slice(2)}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (line.startsWith("+ ")) {
|
||||
if (stripped.startsWith("+ ")) {
|
||||
return (
|
||||
<Text key={key} color="green">
|
||||
{line}
|
||||
</Text>
|
||||
<Box key={key}>
|
||||
<Text color="#4ade80" bold>
|
||||
{"+ "}
|
||||
</Text>
|
||||
<Text color="#86efac">{stripped.slice(2)}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Context line (starts with " ") or unknown — dim.
|
||||
// Context line (starts with " ") or unknown — dim.
|
||||
return (
|
||||
<Text key={key} dimColor>
|
||||
{line}
|
||||
</Text>
|
||||
<Box key={key}>
|
||||
<Text dimColor>{" "}</Text>
|
||||
<Text dimColor>{stripped}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
@@ -499,21 +598,36 @@ function EditFileDiff({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
function BranchBlock({ branch }: { branch: BranchSummary }) {
|
||||
const per = branch.uncertainties
|
||||
.map((u, i) => {
|
||||
const marker = i === branch.chosenIndex ? "▸" : " ";
|
||||
const t = (branch.temperatures[i] ?? 0).toFixed(1);
|
||||
return `${marker} #${i} T=${t} u=${u}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<Box>
|
||||
<Text color="blue">
|
||||
{"⎇ branched "}
|
||||
<Text bold>{branch.budget}</Text>
|
||||
{` samples → picked #${branch.chosenIndex} `}
|
||||
<Text dimColor>{per}</Text>
|
||||
</Text>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text backgroundColor="#93c5fd" color="black" bold>
|
||||
{` ⎇ BRANCH ×${branch.budget} `}
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="#93c5fd">picked </Text>
|
||||
<Text color="#93c5fd" bold>
|
||||
#{branch.chosenIndex}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2} marginTop={1}>
|
||||
{branch.uncertainties.map((u, i) => {
|
||||
const chosen = i === branch.chosenIndex;
|
||||
const t = (branch.temperatures[i] ?? 0).toFixed(1);
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: branch index is positional and stable
|
||||
<Text key={`b-${i}`}>
|
||||
<Text color={chosen ? "#93c5fd" : "#475569"} bold={chosen}>
|
||||
{chosen ? "▸ " : " "}
|
||||
</Text>
|
||||
<Text color={chosen ? "#93c5fd" : "#94a3b8"} bold={chosen}>
|
||||
{`#${i}`}
|
||||
</Text>
|
||||
<Text dimColor>{` T=${t} u=${u} `}</Text>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -528,15 +642,14 @@ function ReasoningBlock({ reasoning }: { reasoning: string }) {
|
||||
// Users can dump the full reasoning with `/think` if needed.
|
||||
const preview =
|
||||
flat.length <= max ? flat : `… (+${flat.length - max} earlier chars) ${flat.slice(-max)}`;
|
||||
// ▏ (LEFT ONE EIGHTH BLOCK) renders as a thin vertical rule at the
|
||||
// cell edge — subtler than the role-level ▎ above, giving a
|
||||
// visual hierarchy: thick bar = role, thin bar = nested detail
|
||||
// under that role (thinking, stats, citations, etc).
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>▏ </Text>
|
||||
<Text dimColor italic>
|
||||
thinking {preview}
|
||||
<Text backgroundColor={COLOR.accent} color="black" bold>
|
||||
{" ⋯ thinking "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color={COLOR.accent} italic dimColor>
|
||||
{preview}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -636,27 +749,34 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
const preFirstByte = !event.text && !event.reasoning && !toolCallBuild;
|
||||
const reasoningOnly = !event.text && !!event.reasoning && !toolCallBuild;
|
||||
const toolCallOnly = !event.text && !event.reasoning && !!toolCallBuild;
|
||||
// Phase pill — solid bg color per phase so the user reads
|
||||
// "what's happening" at a glance from across the screen.
|
||||
let pillBg: string;
|
||||
let pillText: string;
|
||||
let label: string;
|
||||
let labelColor: "yellow" | "cyan" | "green" | "magenta" | undefined;
|
||||
if (preFirstByte) {
|
||||
pillBg = "#fbbf24"; // amber
|
||||
pillText = "WAITING";
|
||||
label = "request sent · waiting for server";
|
||||
labelColor = "yellow";
|
||||
} else if (reasoningOnly) {
|
||||
label = `R1 reasoning · ${event.reasoning?.length ?? 0} chars of thought`;
|
||||
labelColor = "cyan";
|
||||
pillBg = "#c4b5fd"; // violet
|
||||
pillText = "THINKING";
|
||||
label = `${event.reasoning?.length ?? 0} chars of thought`;
|
||||
} else if (toolCallOnly) {
|
||||
label = `assembling tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments${formatReadyTail(toolCallBuild)}`;
|
||||
labelColor = "magenta";
|
||||
pillBg = "#f0abfc"; // fuchsia
|
||||
pillText = "DISPATCH";
|
||||
label = `assembling${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars${formatReadyTail(toolCallBuild)}`;
|
||||
} else {
|
||||
const parts: string[] = [`writing response · ${event.text.length} chars`];
|
||||
if (event.reasoning) parts.push(`after ${event.reasoning.length} chars of reasoning`);
|
||||
pillBg = "#86efac"; // green
|
||||
pillText = "WRITING";
|
||||
const parts: string[] = [`${event.text.length} chars`];
|
||||
if (event.reasoning) parts.push(`after ${event.reasoning.length} reasoning`);
|
||||
if (toolCallBuild) {
|
||||
parts.push(
|
||||
`building tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars${formatReadyTail(toolCallBuild)}`,
|
||||
`tool${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> ${toolCallBuild.chars}c${formatReadyTail(toolCallBuild)}`,
|
||||
);
|
||||
}
|
||||
label = parts.join(" · ");
|
||||
labelColor = "green";
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
@@ -664,36 +784,50 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
<PulsingAssistantGlyph />
|
||||
<Text>{" "}</Text>
|
||||
<Pulse />
|
||||
<Text color={labelColor}>{` ${label} `}</Text>
|
||||
<Text> </Text>
|
||||
<Text backgroundColor={pillBg} color="black" bold>
|
||||
{` ${pillText} `}
|
||||
</Text>
|
||||
<Text dimColor>{` ${label} `}</Text>
|
||||
<Elapsed />
|
||||
</Box>
|
||||
{reasoningTail ? (
|
||||
<Text dimColor italic>
|
||||
↳ thinking: {reasoningTail}
|
||||
</Text>
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="#c4b5fd" italic dimColor>
|
||||
↳ {reasoningTail}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{tail ? (
|
||||
<Text dimColor>▸ {tail}</Text>
|
||||
<Box paddingLeft={3}>
|
||||
<Text dimColor>▸ {tail}</Text>
|
||||
</Box>
|
||||
) : preFirstByte ? (
|
||||
// Non-dim yellow: first-time users misread the dim version as
|
||||
// Non-dim amber: first-time users misread the dim version as
|
||||
// "app frozen". The reassurance has to be VISIBLE to do its job.
|
||||
<Text color="yellow" italic>
|
||||
{" waiting for first byte — this is normal, typically 5-60s depending on model + load"}
|
||||
</Text>
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="#fbbf24" italic>
|
||||
{"waiting for first byte — typical 5–60s depending on model + load"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : reasoningOnly ? (
|
||||
<Text color="yellow" italic>
|
||||
{
|
||||
" R1 is thinking before it speaks — body text arrives when reasoning finishes (typically 20-90s, this is normal)"
|
||||
}
|
||||
</Text>
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="#c4b5fd" italic>
|
||||
{"R1 thinks before it speaks — body text arrives when reasoning finishes (20–90s)"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : toolCallOnly ? (
|
||||
<Text color="magenta" italic>
|
||||
{" tool-call arguments streaming — the model is about to dispatch a tool"}
|
||||
</Text>
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="#f0abfc" italic>
|
||||
{"tool-call arguments streaming — about to dispatch"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : event.reasoning ? (
|
||||
<Text color="yellow" italic>
|
||||
{" R1 still reasoning — body text or tool call arrives when thinking finishes"}
|
||||
</Text>
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="#fbbf24" italic>
|
||||
{"R1 still reasoning — body text or tool call arrives when thinking finishes"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
@@ -742,19 +876,26 @@ function lastLine(s: string, maxChars: number): string {
|
||||
|
||||
function StatsLine({ stats }: { stats: TurnStats }) {
|
||||
const hit = (stats.cacheHitRatio * 100).toFixed(1);
|
||||
const hitColor =
|
||||
stats.cacheHitRatio >= 0.7 ? "#4ade80" : stats.cacheHitRatio >= 0.4 ? "#fcd34d" : "#f87171";
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>▏ </Text>
|
||||
<Text dimColor>
|
||||
{"cache "}
|
||||
{hit}
|
||||
{"% · tokens "}
|
||||
{stats.usage.promptTokens}
|
||||
{" → "}
|
||||
{stats.usage.completionTokens}
|
||||
{" · $"}
|
||||
{stats.cost.toFixed(6)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={hitColor} bold>
|
||||
{`⌬ ${hit}%`}
|
||||
</Text>
|
||||
<Text dimColor>{" · "}</Text>
|
||||
<Text color="#94a3b8">
|
||||
{"in "}
|
||||
<Text color="#67e8f9" bold>
|
||||
{stats.usage.promptTokens}
|
||||
</Text>
|
||||
{" → out "}
|
||||
<Text color="#c4b5fd" bold>
|
||||
{stats.usage.completionTokens}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text dimColor>{" · "}</Text>
|
||||
<Text color="#86efac" bold>{`$${stats.cost.toFixed(6)}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+108
-50
@@ -1,4 +1,5 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform)
|
||||
import React from "react";
|
||||
import type { ApplyResult } from "../../code/edit-blocks.js";
|
||||
import type { EditMode } from "../../config.js";
|
||||
@@ -19,10 +20,13 @@ export function StatusRow({ text }: { text: string }) {
|
||||
const tick = useTick();
|
||||
const elapsed = useElapsedSeconds();
|
||||
return (
|
||||
<Box marginY={1}>
|
||||
<Text color="magenta">{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}</Text>
|
||||
<Text color="magenta">{` ${text}`}</Text>
|
||||
<Text dimColor>{` ${elapsed}s`}</Text>
|
||||
<Box marginY={1} paddingX={1}>
|
||||
<Text color="#c4b5fd" bold>
|
||||
{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="#c4b5fd">{text}</Text>
|
||||
<Text dimColor>{` · ${elapsed}s`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -63,41 +67,82 @@ export function ModeStatusBar({
|
||||
running > 0 ? (
|
||||
<Text color="yellow" bold>{` · ⏵ ${running} job${running === 1 ? "" : "s"}`}</Text>
|
||||
) : null;
|
||||
// The same mode pill is already in the StatsPanel header — here we
|
||||
// skip the pill and keep just a tight inline hint right above the
|
||||
// input so the user has the actionable bit (what y/n/u/Shift+Tab
|
||||
// do) without the whole tutorial.
|
||||
if (planMode) {
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color="red" bold inverse={flash}>
|
||||
{"▸ PLAN"}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" writes gated — submit_plan + approval required · /plan off to leave"}
|
||||
</Text>
|
||||
<ModeBarFrame>
|
||||
<ModePill label="PLAN MODE" bg="red" flash={flash} />
|
||||
<Text dimColor>{" writes gated · /plan off to leave"}</Text>
|
||||
{jobsTag}
|
||||
</Box>
|
||||
</ModeBarFrame>
|
||||
);
|
||||
}
|
||||
const label = editMode === "auto" ? "AUTO" : "review";
|
||||
const color = editMode === "auto" ? "magenta" : "cyan";
|
||||
const mid =
|
||||
editMode === "auto"
|
||||
? undoArmed
|
||||
? "edits apply immediately · u = undo last batch"
|
||||
: "edits apply immediately · 5s undo window after each batch"
|
||||
: pendingCount > 0
|
||||
? `${pendingCount} queued · y = /apply · n = /discard`
|
||||
: "edits queue for review · y = /apply · n = /discard";
|
||||
const flip = editMode === "auto" ? "Shift+Tab → review" : "Shift+Tab → AUTO";
|
||||
const isAuto = editMode === "auto";
|
||||
const label = isAuto ? "AUTO" : "REVIEW";
|
||||
const bg = isAuto ? "magenta" : "cyan";
|
||||
const mid = isAuto
|
||||
? "edits land now · u to undo"
|
||||
: pendingCount > 0
|
||||
? `${pendingCount} queued · y apply · n discard`
|
||||
: "edits queued · y apply · n discard";
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color={color} bold inverse={flash}>
|
||||
{`▸ ${label}`}
|
||||
</Text>
|
||||
<Text dimColor>{` ${mid} · ${flip}`}</Text>
|
||||
<ModeBarFrame>
|
||||
<ModePill label={label} bg={bg} flash={flash} />
|
||||
<Text dimColor>{` ${mid} · Shift+Tab to flip`}</Text>
|
||||
{jobsTag}
|
||||
</ModeBarFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the bottom mode/jobs row in a dim top rule + side padding
|
||||
* so the modeline reads as its own zone, separate from the prompt
|
||||
* input above. Keeps the live region border-free (the rule is a
|
||||
* single Text row, not a bordered Box) so Ink's eraseLines miscount
|
||||
* stays out of it.
|
||||
*/
|
||||
function ModeBarFrame({ children }: { children: React.ReactNode }) {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const ruleWidth = Math.max(20, cols - 2);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1}>
|
||||
<Text color="#475569" dimColor>
|
||||
{"╌".repeat(ruleWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingX={1}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Solid-background mode badge so the bottom status bar reads at a
|
||||
* glance like a real editor's modeline (vim/helix style) instead of a
|
||||
* dim text label that blends into the prompt above. `flash` inverts
|
||||
* briefly after a mode flip — the existing modeFlash signal — for a
|
||||
* "yes, it changed" acknowledgement.
|
||||
*/
|
||||
function ModePill({
|
||||
label,
|
||||
bg,
|
||||
flash,
|
||||
}: {
|
||||
label: string;
|
||||
bg: "magenta" | "cyan" | "red";
|
||||
flash: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Text backgroundColor={bg} color="white" bold inverse={flash}>
|
||||
{` ${label} `}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Just auto-applied N edits — press u to undo" banner. Rendered below
|
||||
* the live rows after an auto-mode edit batch lands, visible for 5s.
|
||||
@@ -115,19 +160,20 @@ export function UndoBanner({
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
const ok = banner.results.filter((r) => r.status === "applied" || r.status === "created").length;
|
||||
const total = banner.results.length;
|
||||
const urgent = remainingSec <= 1;
|
||||
return (
|
||||
<Box marginY={1} borderStyle="round" borderColor="magenta" paddingX={1}>
|
||||
<Text color="magenta" bold>
|
||||
{"✓ auto-applied "}
|
||||
<Box marginY={1} paddingX={1}>
|
||||
<Text backgroundColor="#c4b5fd" color="black" bold>
|
||||
{` ✓ AUTO-APPLIED ${ok}/${total} `}
|
||||
</Text>
|
||||
<Text color="magenta">{`${ok}/${total} edit${total === 1 ? "" : "s"}`}</Text>
|
||||
<Text dimColor>{" · press "}</Text>
|
||||
<Text color="magenta" bold>
|
||||
{"u"}
|
||||
<Text dimColor>{" press "}</Text>
|
||||
<Text backgroundColor="#67e8f9" color="black" bold>
|
||||
{" u "}
|
||||
</Text>
|
||||
<Text dimColor>{" to undo "}</Text>
|
||||
<Text color={urgent ? "#f87171" : "#c4b5fd"} bold={urgent}>
|
||||
{`${remainingSec}s`}
|
||||
</Text>
|
||||
<Text dimColor>{" to undo ("}</Text>
|
||||
<Text color={remainingSec <= 1 ? "red" : "magenta"}>{`${remainingSec}s`}</Text>
|
||||
<Text dimColor>{")"}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -147,10 +193,16 @@ export function SubagentRow({
|
||||
const tick = useTick();
|
||||
const seconds = (activity.elapsedMs / 1000).toFixed(1);
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="magenta">{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}</Text>
|
||||
<Text color="magenta">{` ⌬ subagent: ${activity.task}`}</Text>
|
||||
<Text dimColor>{` · iter ${activity.iter} · ${seconds}s`}</Text>
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="#c4b5fd" bold>
|
||||
{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text backgroundColor="#c4b5fd" color="black" bold>
|
||||
{" ⌬ subagent "}
|
||||
</Text>
|
||||
<Text color="#c4b5fd">{` ${activity.task}`}</Text>
|
||||
<Text dimColor>{` iter ${activity.iter} · ${seconds}s`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -181,19 +233,25 @@ export function OngoingToolRow({
|
||||
const elapsed = useElapsedSeconds();
|
||||
const summary = summarizeToolArgs(tool.name, tool.args);
|
||||
return (
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Box marginY={1} flexDirection="column" paddingX={1}>
|
||||
<Box>
|
||||
<Text color="cyan">{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}</Text>
|
||||
<Text color="yellow">{` tool<${tool.name}> running…`}</Text>
|
||||
<Text dimColor>{` ${elapsed}s`}</Text>
|
||||
<Text color="#fcd34d" bold>
|
||||
{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text backgroundColor="#fcd34d" color="black" bold>
|
||||
{` ⏵ ${tool.name} `}
|
||||
</Text>
|
||||
<Text color="#fcd34d">{" running"}</Text>
|
||||
<Text dimColor>{` · ${elapsed}s`}</Text>
|
||||
</Box>
|
||||
{progress ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="cyan">{renderProgressLine(progress)}</Text>
|
||||
<Box paddingLeft={3}>
|
||||
<Text color="#67e8f9">{renderProgressLine(progress)}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{summary ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Box paddingLeft={3}>
|
||||
<Text dimColor>{summary}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Shared visual frame for confirmation modals (ChoiceConfirm,
|
||||
* PlanConfirm, ShellConfirm, EditConfirm, PlanCheckpointConfirm,
|
||||
* PlanReviseConfirm). Each modal opens inside the live region — so
|
||||
* we keep the frame to plain Text rules and bg-pill headers, no
|
||||
* Ink `borderStyle` (that's the eraseLines miscount class of bug).
|
||||
*
|
||||
* Layout:
|
||||
*
|
||||
* ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
|
||||
* [ ICON TITLE ] optional subtitle
|
||||
*
|
||||
* <children — body of the modal>
|
||||
*
|
||||
* ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
|
||||
*
|
||||
* Color drives the accent (top + bottom rules + title pill bg)
|
||||
* so the user learns to recognize "magenta = choice", "cyan =
|
||||
* plan", "red = shell", "yellow = edit review" by sight.
|
||||
*/
|
||||
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope
|
||||
import React from "react";
|
||||
|
||||
export interface ModalCardProps {
|
||||
/** Accent color — title pill bg + top/bottom rule color. */
|
||||
accent: string;
|
||||
/** Section title shown in the bg-pill header. */
|
||||
title: string;
|
||||
/** Optional dim subtitle next to the title. */
|
||||
subtitle?: string;
|
||||
/** Optional leading glyph inside the title pill (icon). */
|
||||
icon?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ModalCard({
|
||||
accent,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
children,
|
||||
}: ModalCardProps): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const ruleWidth = Math.min(80, Math.max(28, cols - 4));
|
||||
const titleText = icon ? ` ${icon} ${title} ` : ` ${title} `;
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text color={accent}>{"▔".repeat(ruleWidth)}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text backgroundColor={accent} color="black" bold>
|
||||
{titleText}
|
||||
</Text>
|
||||
{subtitle ? <Text dimColor>{` ${subtitle}`}</Text> : null}
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{children}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={accent} dimColor>
|
||||
{"▁".repeat(ruleWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,10 @@
|
||||
* no need to re-render the step body here.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import { Box } from "ink";
|
||||
import React from "react";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { ModalCard } from "./ModalCard.js";
|
||||
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
|
||||
@@ -46,48 +47,41 @@ function PlanCheckpointConfirmInner({
|
||||
onChoose,
|
||||
}: PlanCheckpointConfirmProps) {
|
||||
const label = title ? `${stepId} · ${title}` : stepId;
|
||||
const counter = total > 0 ? ` (${completed}/${total})` : "";
|
||||
const counter = total > 0 ? `${completed}/${total}` : "";
|
||||
const isLast = total > 0 && completed >= total;
|
||||
const statuses = buildStatusMap(steps, completedStepIds, stepId, isLast);
|
||||
const subtitle = counter ? `${counter} · ${label}` : label;
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
▸ checkpoint — step done
|
||||
</Text>
|
||||
<Text dimColor>{` ${label}${counter}`}</Text>
|
||||
</Box>
|
||||
<ModalCard accent="#86efac" icon="✓" title="checkpoint — step done" subtitle={subtitle}>
|
||||
{steps && steps.length > 0 ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<PlanStepList steps={steps} statuses={statuses} focusStepId={stepId} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<SingleSelect
|
||||
initialValue={isLast ? "stop" : "continue"}
|
||||
items={[
|
||||
{
|
||||
value: "continue",
|
||||
label: "Continue — run the next step",
|
||||
hint: "Model resumes with the next step. Use this when the result looks right and you don't need to tweak the remaining plan.",
|
||||
},
|
||||
{
|
||||
value: "revise",
|
||||
label: "Revise — give feedback before the next step",
|
||||
hint: "Stay paused, type guidance (scope changes, skip steps, alternative approach). The model adjusts the remaining plan based on your message.",
|
||||
},
|
||||
{
|
||||
value: "stop",
|
||||
label: "Stop — end the plan here",
|
||||
hint: "Model summarizes what was done and ends. Remaining steps are skipped.",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as CheckpointChoice)}
|
||||
onCancel={() => onChoose("stop")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] stop"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<SingleSelect
|
||||
initialValue={isLast ? "stop" : "continue"}
|
||||
items={[
|
||||
{
|
||||
value: "continue",
|
||||
label: "Continue — run the next step",
|
||||
hint: "Model resumes with the next step. Use this when the result looks right and you don't need to tweak the remaining plan.",
|
||||
},
|
||||
{
|
||||
value: "revise",
|
||||
label: "Revise — give feedback before the next step",
|
||||
hint: "Stay paused, type guidance (scope changes, skip steps, alternative approach). The model adjusts the remaining plan based on your message.",
|
||||
},
|
||||
{
|
||||
value: "stop",
|
||||
label: "Stop — end the plan here",
|
||||
hint: "Model summarizes what was done and ends. Remaining steps are skipped.",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as CheckpointChoice)}
|
||||
onCancel={() => onChoose("stop")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] stop"
|
||||
/>
|
||||
</ModalCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+34
-42
@@ -21,6 +21,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { ModalCard } from "./ModalCard.js";
|
||||
import { PlanStepList } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
|
||||
@@ -44,22 +45,15 @@ function PlanConfirmInner({ plan, steps, summary, onChoose }: PlanConfirmProps)
|
||||
/^#{1,6}\s*(待确认|开放问题|风险|未知|假设|不确定)/im.test(plan);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginY={1}>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
▸ plan proposed (full text above) — approve / refine / cancel
|
||||
</Text>
|
||||
</Box>
|
||||
{summary ? (
|
||||
<Box>
|
||||
<Text color="cyan">{` ${summary}`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<ModalCard
|
||||
accent="#67e8f9"
|
||||
icon="📋"
|
||||
title="plan proposed"
|
||||
subtitle={summary ?? "approve / refine / cancel"}
|
||||
>
|
||||
{hasOpenQuestions ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">
|
||||
<Box marginBottom={1}>
|
||||
<Text color="#fbbf24">
|
||||
▲ the plan flags open questions or risks — pick{" "}
|
||||
<Text bold>Refine / answer questions</Text> to write concrete answers before the model
|
||||
moves on.
|
||||
@@ -67,36 +61,34 @@ function PlanConfirmInner({ plan, steps, summary, onChoose }: PlanConfirmProps)
|
||||
</Box>
|
||||
) : null}
|
||||
{steps && steps.length > 0 ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<PlanStepList steps={steps} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<SingleSelect
|
||||
initialValue={hasOpenQuestions ? "refine" : "approve"}
|
||||
items={[
|
||||
{
|
||||
value: "approve",
|
||||
label: "Approve and implement",
|
||||
hint: "Exit plan mode. The model starts executing. You'll get a text input to add any last instructions (or just press Enter to skip).",
|
||||
},
|
||||
{
|
||||
value: "refine",
|
||||
label: "Refine / answer questions",
|
||||
hint: "Stay in plan mode. Write answers, modifications, or critiques; the model revises and re-submits.",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel",
|
||||
hint: "Exit plan mode. Drop the plan; the model won't implement it.",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as PlanConfirmChoice)}
|
||||
onCancel={() => onChoose("cancel")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] cancel"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<SingleSelect
|
||||
initialValue={hasOpenQuestions ? "refine" : "approve"}
|
||||
items={[
|
||||
{
|
||||
value: "approve",
|
||||
label: "Approve and implement",
|
||||
hint: "Exit plan mode. The model starts executing. You'll get a text input to add any last instructions (or just press Enter to skip).",
|
||||
},
|
||||
{
|
||||
value: "refine",
|
||||
label: "Refine / answer questions",
|
||||
hint: "Stay in plan mode. Write answers, modifications, or critiques; the model revises and re-submits.",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel",
|
||||
hint: "Exit plan mode. Drop the plan; the model won't implement it.",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as PlanConfirmChoice)}
|
||||
onCancel={() => onChoose("cancel")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] cancel"
|
||||
/>
|
||||
</ModalCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
* the picker without resuming.
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { ModalCard } from "./ModalCard.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { useTick } from "./ticker.js";
|
||||
|
||||
export interface PlanRefineInputProps {
|
||||
/**
|
||||
@@ -37,38 +40,65 @@ export interface PlanRefineInputProps {
|
||||
export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) {
|
||||
useKeystroke((ev) => {
|
||||
if (ev.paste) {
|
||||
// Insert paste content as-is. Multi-line pastes flatten via
|
||||
// newlines becoming spaces because this is a single-line input.
|
||||
setValue((v) => v + ev.input.replace(/\r?\n/g, " "));
|
||||
return;
|
||||
}
|
||||
if (ev.escape) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
if (ev.return) {
|
||||
onSubmit(value.trim());
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
if (ev.backspace || ev.delete) {
|
||||
setValue((v) => v.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
// Filter out non-printable chars; accept ordinary text + CJK.
|
||||
if (input && !key.ctrl && !key.meta) {
|
||||
setValue((v) => v + input);
|
||||
if (ev.input && !ev.ctrl && !ev.meta) {
|
||||
setValue((v) => v + ev.input);
|
||||
}
|
||||
});
|
||||
|
||||
const title =
|
||||
const tick = useTick();
|
||||
// Slow blink for the cursor block (~480 ms on/off). Same cadence
|
||||
// as the wordmark's brand-mark pulse; reads as "input is alive".
|
||||
const cursorOn = Math.floor(tick / 4) % 2 === 0;
|
||||
|
||||
const meta =
|
||||
mode === "approve"
|
||||
? "▸ approving — any last instructions or answers to open questions?"
|
||||
? {
|
||||
title: "approving — any last instructions?",
|
||||
icon: "📋",
|
||||
accent: "#67e8f9",
|
||||
}
|
||||
: mode === "checkpoint-revise"
|
||||
? "▸ revising — what should change before the next step?"
|
||||
? {
|
||||
title: "revising — what should change before the next step?",
|
||||
icon: "✏",
|
||||
accent: "#fbbf24",
|
||||
}
|
||||
: mode === "choice-custom"
|
||||
? "▸ custom answer — type whatever fits"
|
||||
: "▸ refining — what should the model change?";
|
||||
? {
|
||||
title: "custom answer — type whatever fits",
|
||||
icon: "🔀",
|
||||
accent: "#f0abfc",
|
||||
}
|
||||
: {
|
||||
title: "refining — what should the model change?",
|
||||
icon: "✏",
|
||||
accent: "#fbbf24",
|
||||
};
|
||||
const hint =
|
||||
mode === "approve"
|
||||
? "Answer questions the plan raised, add constraints, or just press Enter to approve as-is."
|
||||
: mode === "checkpoint-revise"
|
||||
? "Scope change, skip steps, alternative approach — the model will adjust the remaining plan based on this."
|
||||
? "Scope change, skip steps, alternative approach — the model adjusts the remaining plan."
|
||||
: mode === "choice-custom"
|
||||
? "Free-form reply. The model reads it verbatim and proceeds — no need to match the listed options."
|
||||
: "Describe what's wrong or missing, or answer questions the plan raised.";
|
||||
@@ -82,25 +112,22 @@ export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputPro
|
||||
: " (Enter with blank = ask the model to list concrete questions.)";
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="yellow">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<ModalCard accent={meta.accent} icon={meta.icon} title={meta.title}>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
{hint} Enter to send · Esc to return to the picker.
|
||||
{value === "" ? blankHint : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Text color="yellow">› </Text>
|
||||
<Text>{value || " "}</Text>
|
||||
<Text color="yellow">▍</Text>
|
||||
<Box>
|
||||
<Text color={meta.accent} bold>
|
||||
{"› "}
|
||||
</Text>
|
||||
<Text>{value}</Text>
|
||||
<Text color={meta.accent} bold>
|
||||
{cursorOn ? "▍" : " "}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</ModalCard>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { ModalCard } from "./ModalCard.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
|
||||
export type ReviseChoice = "accept" | "reject";
|
||||
@@ -53,19 +54,16 @@ function computeDiff(oldSteps: PlanStep[], newSteps: PlanStep[]): DiffRow[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
function riskDots(risk: PlanStep["risk"]): {
|
||||
dots: string;
|
||||
color: "green" | "yellow" | "red" | "gray";
|
||||
} {
|
||||
function riskDots(risk: PlanStep["risk"]): { dots: string; color: string } {
|
||||
switch (risk) {
|
||||
case "high":
|
||||
return { dots: "●●●", color: "red" };
|
||||
return { dots: "●●●", color: "#f87171" };
|
||||
case "med":
|
||||
return { dots: "●● ", color: "yellow" };
|
||||
return { dots: "●● ", color: "#fbbf24" };
|
||||
case "low":
|
||||
return { dots: "● ", color: "green" };
|
||||
return { dots: "● ", color: "#4ade80" };
|
||||
default:
|
||||
return { dots: " ", color: "gray" };
|
||||
return { dots: " ", color: "#94a3b8" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,27 +79,26 @@ function PlanReviseConfirmInner({
|
||||
const addedCount = rows.filter((r) => r.kind === "added").length;
|
||||
const keptCount = rows.filter((r) => r.kind === "kept").length;
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="yellow">
|
||||
✏ plan revision proposed
|
||||
</Text>
|
||||
<Text dimColor>{` −${removedCount} +${addedCount} (${keptCount} kept)`}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<ModalCard
|
||||
accent="#fbbf24"
|
||||
icon="✏"
|
||||
title="plan revision proposed"
|
||||
subtitle={`−${removedCount} +${addedCount} · ${keptCount} kept`}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text>{reason}</Text>
|
||||
</Box>
|
||||
{summary ? (
|
||||
<Box marginTop={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>{`updated summary: ${summary}`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
{rows.map((row) => {
|
||||
const risk = riskDots(row.step.risk);
|
||||
const prefix = row.kind === "removed" ? "−" : row.kind === "added" ? "+" : " ";
|
||||
const prefixColor =
|
||||
row.kind === "removed" ? "red" : row.kind === "added" ? "green" : "gray";
|
||||
row.kind === "removed" ? "#f87171" : row.kind === "added" ? "#4ade80" : "#94a3b8";
|
||||
const dim = row.kind === "kept";
|
||||
const strike = row.kind === "removed";
|
||||
return (
|
||||
@@ -119,27 +116,25 @@ function PlanReviseConfirmInner({
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<SingleSelect
|
||||
initialValue="accept"
|
||||
items={[
|
||||
{
|
||||
value: "accept",
|
||||
label: "Accept revision — apply the new step list",
|
||||
hint: "Replaces the remaining plan with the proposed steps. Done steps are untouched.",
|
||||
},
|
||||
{
|
||||
value: "reject",
|
||||
label: "Reject — keep the original plan",
|
||||
hint: "Drops the proposal. Model continues with the original remaining steps.",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as ReviseChoice)}
|
||||
onCancel={() => onChoose("reject")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] reject"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<SingleSelect
|
||||
initialValue="accept"
|
||||
items={[
|
||||
{
|
||||
value: "accept",
|
||||
label: "Accept revision — apply the new step list",
|
||||
hint: "Replaces the remaining plan with the proposed steps. Done steps are untouched.",
|
||||
},
|
||||
{
|
||||
value: "reject",
|
||||
label: "Reject — keep the original plan",
|
||||
hint: "Drops the proposal. Model continues with the original remaining steps.",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as ReviseChoice)}
|
||||
onCancel={() => onChoose("reject")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] reject"
|
||||
/>
|
||||
</ModalCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,30 +18,29 @@ import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import type { TypedPlanState } from "../../harvest.js";
|
||||
|
||||
type FieldColor = "cyan" | "green" | "yellow" | "red";
|
||||
type FieldColor = string;
|
||||
|
||||
export function PlanStateBlock({ planState }: { planState: TypedPlanState }) {
|
||||
const fields: Array<[string, string[], FieldColor, boolean]> = [];
|
||||
if (planState.subgoals.length) fields.push(["subgoals", planState.subgoals, "cyan", false]);
|
||||
if (planState.subgoals.length) fields.push(["subgoals", planState.subgoals, "#67e8f9", false]);
|
||||
if (planState.hypotheses.length)
|
||||
fields.push(["hypotheses", planState.hypotheses, "green", false]);
|
||||
fields.push(["hypotheses", planState.hypotheses, "#86efac", false]);
|
||||
if (planState.uncertainties.length)
|
||||
fields.push(["uncertainties", planState.uncertainties, "yellow", false]);
|
||||
fields.push(["uncertainties", planState.uncertainties, "#fcd34d", false]);
|
||||
if (planState.rejectedPaths.length)
|
||||
fields.push(["rejected", planState.rejectedPaths, "red", true]);
|
||||
fields.push(["rejected", planState.rejectedPaths, "#94a3b8", true]);
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{fields.map(([label, items, color, dim]) => (
|
||||
<Text key={label}>
|
||||
<Text color={color} bold dimColor={dim}>
|
||||
{"‹ "}
|
||||
{label}
|
||||
<Box key={label}>
|
||||
<Text backgroundColor={color} color="black" bold dimColor={dim}>
|
||||
{` ${label} ${items.length} `}
|
||||
</Text>
|
||||
<Text dimColor>{` (${items.length})`}</Text>
|
||||
<Text>{`: ${items.join(" · ")}`}</Text>
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text dimColor={dim}>{items.join(" · ")}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
+44
-23
@@ -50,22 +50,6 @@ function riskDots(risk: PlanStep["risk"]): {
|
||||
}
|
||||
}
|
||||
|
||||
function statusGlyph(status: StepStatus): {
|
||||
glyph: string;
|
||||
color: "green" | "cyan" | "gray" | "yellow";
|
||||
} {
|
||||
switch (status) {
|
||||
case "done":
|
||||
return { glyph: "✓", color: "green" };
|
||||
case "running":
|
||||
return { glyph: "▶", color: "cyan" };
|
||||
case "skipped":
|
||||
return { glyph: "—", color: "gray" };
|
||||
default:
|
||||
return { glyph: " ", color: "yellow" };
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus(stepId: string, statuses: PlanStepListProps["statuses"]): StepStatus {
|
||||
if (!statuses) return "pending";
|
||||
if (statuses instanceof Map) {
|
||||
@@ -80,13 +64,14 @@ function PlanStepListInner({ steps, statuses, focusStepId }: PlanStepListProps)
|
||||
const doneCount = Array.from({ length: steps.length }, (_, i) =>
|
||||
getStatus(steps[i]!.id, statuses),
|
||||
).filter((s) => s === "done").length;
|
||||
const pct = Math.round((doneCount / steps.length) * 100);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{`${steps.length} step${steps.length === 1 ? "" : "s"}`}
|
||||
{doneCount > 0 ? ` · ${doneCount} done` : ""}
|
||||
{doneCount > 0 ? ` · ${doneCount}/${steps.length} done (${pct}%)` : ""}
|
||||
{hasAnyRisk ? " · risk: " : ""}
|
||||
</Text>
|
||||
{hasAnyRisk ? <RiskLegend /> : null}
|
||||
@@ -96,20 +81,21 @@ function PlanStepListInner({ steps, statuses, focusStepId }: PlanStepListProps)
|
||||
const status = getStatus(step.id, statuses);
|
||||
const focus = focusStepId === step.id;
|
||||
const risk = riskDots(step.risk);
|
||||
const glyph = statusGlyph(status);
|
||||
const titleDim = status === "done" || status === "skipped";
|
||||
return (
|
||||
<Box key={step.id}>
|
||||
<Text color={focus ? "cyan" : "gray"} bold={focus}>
|
||||
{focus ? "› " : " "}
|
||||
<Text color={focus ? "#67e8f9" : "gray"} bold={focus}>
|
||||
{focus ? "▸ " : " "}
|
||||
</Text>
|
||||
<Text color={risk.color} bold>
|
||||
{risk.dots}
|
||||
</Text>
|
||||
<Text color={glyph.color} bold>
|
||||
{` ${glyph.glyph} `}
|
||||
<Text> </Text>
|
||||
<StatusBadge status={status} />
|
||||
<Text> </Text>
|
||||
<Text dimColor={titleDim} bold={focus}>
|
||||
{`${step.id} · ${step.title}`}
|
||||
</Text>
|
||||
<Text dimColor={titleDim}>{`${step.id} · ${step.title}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
@@ -118,6 +104,41 @@ function PlanStepListInner({ steps, statuses, focusStepId }: PlanStepListProps)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status pill — solid-bg badge per step state. Reads at a glance
|
||||
* better than a glyph + color text: ` DONE `, ` RUN `, ` SKIP `,
|
||||
* ` PEND `. The label widths are normalized so the column under
|
||||
* the badge stays aligned even when statuses mix.
|
||||
*/
|
||||
function StatusBadge({ status }: { status: StepStatus }) {
|
||||
switch (status) {
|
||||
case "done":
|
||||
return (
|
||||
<Text backgroundColor="#4ade80" color="black" bold>
|
||||
{" ✓ DONE "}
|
||||
</Text>
|
||||
);
|
||||
case "running":
|
||||
return (
|
||||
<Text backgroundColor="#67e8f9" color="black" bold>
|
||||
{" ▶ RUN "}
|
||||
</Text>
|
||||
);
|
||||
case "skipped":
|
||||
return (
|
||||
<Text backgroundColor="#94a3b8" color="black" bold>
|
||||
{" — SKIP "}
|
||||
</Text>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Text color="#94a3b8" dimColor>
|
||||
{" ☐ PEND "}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function RiskLegend() {
|
||||
return (
|
||||
<Box>
|
||||
|
||||
+448
-402
@@ -1,38 +1,53 @@
|
||||
import { Box, Text, useInput, useStdout } from "ink";
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform compiles JSX to React.createElement)
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { type MultilineKey, lineAndColumn, processMultilineKey } from "./multiline-keys.js";
|
||||
import {
|
||||
PASTE_SENTINEL_RANGE,
|
||||
type PasteEntry,
|
||||
decodePasteSentinel,
|
||||
encodePasteSentinel,
|
||||
expandPasteSentinels,
|
||||
formatBytesShort,
|
||||
listPasteIdsInBuffer,
|
||||
makePasteEntry,
|
||||
} from "./paste-sentinels.js";
|
||||
import { type Segment, buildViewport, stringCells } from "./prompt-viewport.js";
|
||||
import { GRADIENT } from "./theme.js";
|
||||
import { useTick } from "./ticker.js";
|
||||
|
||||
/**
|
||||
* DECSET 2004 (bracketed paste) markers. The terminal sends these
|
||||
* around every paste action when bracketed-paste mode is enabled
|
||||
* (App.tsx writes the enable sequence at mount). Surface as exact
|
||||
* literal byte strings so we can locate them in `input` regardless
|
||||
* of how stdin chunks the surrounding bytes.
|
||||
* Visual anchor: a left vertical rule (▎ + space) running down every
|
||||
* line of the input. Colored with the accent color so disabled state
|
||||
* dims it; gives the input area a clear "you type here" boundary
|
||||
* without using bordered Boxes (those amplified Ink's eraseLines
|
||||
* miscount on Windows). Length is fixed at 2 so the prefix-cell math
|
||||
* stays trivial.
|
||||
*/
|
||||
const PASTE_START_MARKER = "\u001b[200~";
|
||||
const PASTE_END_MARKER = "\u001b[201~";
|
||||
const BAR = "▎ ";
|
||||
|
||||
/**
|
||||
* Merge-fallback window for terminals that strip bracketed-paste
|
||||
* markers (Windows PowerShell + ConPTY + Ink eats `\x1b[200~`).
|
||||
* One Ctrl+V arrives as N chunks 1-5ms apart; we collapse them by
|
||||
* checking arrival time + that the previous sentinel is still at
|
||||
* cursor-1 (no typing happened). 30ms is well below the ~100ms
|
||||
* minimum a human can release+repress Ctrl+V, so deliberate
|
||||
* back-to-back pastes never falsely merge.
|
||||
* Prompt input v2 — no Ink useInput, no Yoga text wrapping, no
|
||||
* cursor blink. Replaces the bordered-Box / multi-Text approach
|
||||
* that was driving the Windows ghost-row regressions.
|
||||
*
|
||||
* Pipeline:
|
||||
* - Subscribes to the global keystroke bus (which is fed by our
|
||||
* own raw stdin parser, NOT Ink's parse-keypress).
|
||||
* - Pure events: `paste` events go to the paste registry; everything
|
||||
* else flows through `processMultilineKey` (the existing reducer
|
||||
* with all the cursor / submit / history-recall semantics).
|
||||
* - Render: one logical line per <Box> row. Each row renders as
|
||||
* EXACTLY one visual row — content longer than the visible cell
|
||||
* budget is clipped via `buildViewport`, with `‹` / `›` markers
|
||||
* where content is hidden. The cursor moves the viewport so it
|
||||
* stays visible.
|
||||
*
|
||||
* Cursor blinks at ~480 ms half-period. The ticker that drives the
|
||||
* blink also flows the left-bar gradient and the wordmark, so
|
||||
* we're already paying for re-render every tick — the cursor blink
|
||||
* is free on top of that. Disabled state freezes the cursor solid
|
||||
* so the disabled prompt doesn't compete with the active spinner.
|
||||
*/
|
||||
const PASTE_MERGE_WINDOW_MS = 30;
|
||||
|
||||
export interface PromptInputProps {
|
||||
value: string;
|
||||
@@ -50,18 +65,6 @@ export interface PromptInputProps {
|
||||
onHistoryNext?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input box with real cursor support. ←/→ move one column, ↑/↓ move
|
||||
* across lines in multi-line buffers, Ctrl+A / Ctrl+E jump to
|
||||
* start/end of the current line. Backspace deletes before cursor,
|
||||
* Delete deletes under cursor. Multi-line composition via Ctrl+J,
|
||||
* Shift+Enter, or bash-style `\<Enter>`.
|
||||
*
|
||||
* Cursor state lives locally. When the parent replaces `value` out
|
||||
* of band (history recall, slash completion, setup wizard) the
|
||||
* cursor jumps to end; the `lastLocalValueRef` guards distinguishes
|
||||
* that case from our own edits.
|
||||
*/
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -72,331 +75,474 @@ export function PromptInput({
|
||||
onHistoryNext,
|
||||
}: PromptInputProps) {
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
// Paste registry. Maps sentinel id → original paste content. Each
|
||||
// entry is keyed by the codepoint stored in the buffer string.
|
||||
// Lives in a ref because rendering doesn't depend on identity —
|
||||
// mutating the map in-place is fine, the per-keystroke render
|
||||
// reads the current state directly. `nextPasteIdRef` wraps modulo
|
||||
// PASTE_SENTINEL_RANGE; collisions are rare in practice (256 slots,
|
||||
// typical session has <10 pastes).
|
||||
|
||||
// Paste registry — keyed by sentinel id, holds original content.
|
||||
const pastesRef = useRef<Map<number, PasteEntry>>(new Map());
|
||||
const nextPasteIdRef = useRef<number>(0);
|
||||
// Bracketed-paste accumulator. When a paste begins, the terminal
|
||||
// sends `\x1b[200~`; when it ends, `\x1b[201~`. We accumulate
|
||||
// everything between markers into ONE entry. Works on terminals
|
||||
// that pass markers through to Ink intact (iTerm, kitty, alacritty,
|
||||
// most modern terminals on macOS/Linux). Doesn't work on Windows
|
||||
// PowerShell+ConPTY where Ink's parse-keypress eats the markers
|
||||
// before we see them — fallback below covers that case.
|
||||
// null = not currently inside a paste; string = accumulating.
|
||||
const pasteAccumRef = useRef<string | null>(null);
|
||||
// Tight time-window merge fallback. When bracketed-paste markers
|
||||
// don't reach us (Ink ate them, terminal didn't send them, etc.),
|
||||
// a single user paste arrives as N stdin chunks 1-5ms apart, and
|
||||
// each chunk on its own looks like a paste burst (multi-line
|
||||
// content). Without merging the user gets four `[paste #N]`
|
||||
// placeholders for one Ctrl+V. With a 30ms window + a guard that
|
||||
// the previous sentinel is still right at cursor-1 (no typing
|
||||
// happened in between), chunks of one paste collapse into one
|
||||
// entry. The window is below human reaction time for double-
|
||||
// pasting (Ctrl+V release → re-press takes 100ms+), so two
|
||||
// deliberate pastes never falsely merge.
|
||||
const lastPasteRef = useRef<{ id: number; at: number } | null>(null);
|
||||
// Tracks the last `value` we ourselves produced via onChange. If the
|
||||
// incoming `value` prop diverges from this, the parent (or some other
|
||||
// source) replaced the buffer — we reset the cursor to end.
|
||||
|
||||
// Track external value replacement (history recall, slash completion).
|
||||
// When the parent swaps `value` we snap cursor to end.
|
||||
const lastLocalValueRef = useRef(value);
|
||||
if (value !== lastLocalValueRef.current) {
|
||||
lastLocalValueRef.current = value;
|
||||
if (cursor !== value.length) {
|
||||
// Conditional setState during render is the "derived state" pattern;
|
||||
// React schedules the re-render and the else branch of the `if`
|
||||
// prevents infinite loops.
|
||||
setCursor(value.length);
|
||||
}
|
||||
if (cursor !== value.length) setCursor(value.length);
|
||||
}
|
||||
// Synchronous mirror of `cursor` for the same reason. When two
|
||||
// useInput firings happen in the same React tick (PowerShell+Ink
|
||||
// emits useInput twice per stdin chunk), the second fire's
|
||||
// closure still sees the stale `cursor` from before the first
|
||||
// fire's setCursor took effect. Reading from a ref we update
|
||||
// synchronously inside `registerPaste` lets the second fire's
|
||||
// merge guard see the just-inserted sentinel at cursor-1.
|
||||
const cursorRef = useRef(cursor);
|
||||
cursorRef.current = cursor;
|
||||
|
||||
// Shared ticker drives the cursor blink. Dividing the tick by 4 lands
|
||||
// the visible on/off cycle around 480ms — standard cursor cadence.
|
||||
const tick = useTick();
|
||||
const showCursor = disabled ? false : Math.floor(tick / 4) % 2 === 0;
|
||||
|
||||
// Helper: register an accumulated paste blob and insert its
|
||||
// sentinel into the buffer at the current cursor. If the previous
|
||||
// paste's sentinel is still at cursor-1 AND we're inside the
|
||||
// sub-human-reaction merge window, append to that entry instead
|
||||
// of creating a new sentinel — this collapses chunks of one
|
||||
// user paste action that arrived as multiple stdin reads (the
|
||||
// PowerShell+ConPTY+Ink case where bracketed-paste markers got
|
||||
// eaten upstream).
|
||||
/**
|
||||
* Register one paste blob and insert its sentinel at the current
|
||||
* cursor. Used both by `paste` events from the stdin reader (the
|
||||
* normal path on terminals that support DECSET 2004) and by the
|
||||
* fallback path inside `processMultilineKey` for chunks that
|
||||
* arrived without bracketed-paste markers.
|
||||
*/
|
||||
const registerPaste = (content: string) => {
|
||||
// Read from refs, NOT from closure variables. PowerShell+Ink
|
||||
// fires useInput twice per stdin chunk; both fires close over
|
||||
// the same stale `value` and `cursor` from React state. Refs
|
||||
// get updated synchronously below, so the second fire sees the
|
||||
// just-inserted sentinel at cursor-1 and the merge guard fires
|
||||
// correctly. This was the core reason a single Ctrl+V kept
|
||||
// producing four `[paste #N]` placeholders.
|
||||
const v = lastLocalValueRef.current;
|
||||
const c = cursorRef.current;
|
||||
const now = Date.now();
|
||||
const last = lastPasteRef.current;
|
||||
const prevChar = c > 0 ? v[c - 1] : null;
|
||||
const prevId = prevChar ? decodePasteSentinel(prevChar) : null;
|
||||
const canMerge =
|
||||
last !== null &&
|
||||
prevId === last.id &&
|
||||
now - last.at < PASTE_MERGE_WINDOW_MS &&
|
||||
pastesRef.current.has(last.id);
|
||||
if (canMerge && last) {
|
||||
const existing = pastesRef.current.get(last.id);
|
||||
if (existing) {
|
||||
const merged = existing.content + content;
|
||||
pastesRef.current.set(last.id, makePasteEntry(last.id, merged));
|
||||
lastPasteRef.current = { id: last.id, at: now };
|
||||
return;
|
||||
}
|
||||
}
|
||||
const c = cursor;
|
||||
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
|
||||
nextPasteIdRef.current = id + 1;
|
||||
pastesRef.current.set(id, makePasteEntry(id, content));
|
||||
const sentinel = encodePasteSentinel(id);
|
||||
const next = v.slice(0, c) + sentinel + v.slice(c);
|
||||
// Update refs synchronously so the SECOND useInput fire (same
|
||||
// React tick) sees the new buffer + cursor. Then schedule the
|
||||
// React state updates for re-render.
|
||||
lastLocalValueRef.current = next;
|
||||
cursorRef.current = c + 1;
|
||||
onChange(next);
|
||||
setCursor(c + 1);
|
||||
lastPasteRef.current = { id, at: now };
|
||||
};
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Bracketed-paste accumulator. The terminal tells us EXACTLY
|
||||
// when a paste begins / ends via `\x1b[200~` / `\x1b[201~`.
|
||||
// Even when a 50KB paste arrives across many stdin reads,
|
||||
// the markers bracket the entire paste once. We accumulate
|
||||
// everything between them into a single sentinel — solving
|
||||
// the "one user paste shows up as N adjacent placeholders"
|
||||
// bug without resorting to time-based heuristics that would
|
||||
// wrongly merge two quick deliberate pastes. On terminals
|
||||
// that don't support DECSET 2004 (cmd.exe), markers never
|
||||
// appear and we fall back to the per-chunk pasteRequest
|
||||
// path below — chunks split visually but stay correct.
|
||||
if (pasteAccumRef.current !== null) {
|
||||
const endIdx = input.indexOf(PASTE_END_MARKER);
|
||||
if (endIdx === -1) {
|
||||
pasteAccumRef.current += input;
|
||||
return;
|
||||
}
|
||||
const content = pasteAccumRef.current + input.slice(0, endIdx);
|
||||
pasteAccumRef.current = null;
|
||||
registerPaste(content);
|
||||
return;
|
||||
}
|
||||
const startIdx = input.indexOf(PASTE_START_MARKER);
|
||||
if (startIdx !== -1) {
|
||||
const afterStart = input.slice(startIdx + PASTE_START_MARKER.length);
|
||||
const endIdx = afterStart.indexOf(PASTE_END_MARKER);
|
||||
if (endIdx !== -1) {
|
||||
// Whole paste in one read — register and we're done.
|
||||
registerPaste(afterStart.slice(0, endIdx));
|
||||
} else {
|
||||
// Open paste mode for subsequent useInput events.
|
||||
pasteAccumRef.current = afterStart;
|
||||
}
|
||||
return;
|
||||
useKeystroke((ev) => {
|
||||
if (disabled) return;
|
||||
if (ev.paste) {
|
||||
// Bracketed-paste content delivered by the stdin reader.
|
||||
// Insert as one sentinel regardless of length.
|
||||
if (ev.input.length > 0) registerPaste(ev.input);
|
||||
return;
|
||||
}
|
||||
const key: MultilineKey = {
|
||||
input: ev.input,
|
||||
return: ev.return,
|
||||
shift: ev.shift,
|
||||
ctrl: ev.ctrl,
|
||||
meta: ev.meta,
|
||||
backspace: ev.backspace,
|
||||
delete: ev.delete,
|
||||
tab: ev.tab,
|
||||
upArrow: ev.upArrow,
|
||||
downArrow: ev.downArrow,
|
||||
leftArrow: ev.leftArrow,
|
||||
rightArrow: ev.rightArrow,
|
||||
escape: ev.escape,
|
||||
pageUp: ev.pageUp,
|
||||
pageDown: ev.pageDown,
|
||||
};
|
||||
const action = processMultilineKey(value, cursor, key);
|
||||
if (action.pasteRequest) {
|
||||
registerPaste(action.pasteRequest.content);
|
||||
return;
|
||||
}
|
||||
if (action.next !== null) {
|
||||
lastLocalValueRef.current = action.next;
|
||||
onChange(action.next);
|
||||
}
|
||||
if (action.cursor !== null) {
|
||||
setCursor(action.cursor);
|
||||
}
|
||||
if (action.submit) {
|
||||
const raw = action.submitValue ?? value;
|
||||
const expanded = expandPasteSentinels(raw, pastesRef.current);
|
||||
// GC unreachable paste entries — anything backspace'd out of
|
||||
// the buffer before submit. Keeps the registry from growing
|
||||
// unbounded across many turns.
|
||||
const reachable = new Set(listPasteIdsInBuffer(raw));
|
||||
for (const id of pastesRef.current.keys()) {
|
||||
if (!reachable.has(id)) pastesRef.current.delete(id);
|
||||
}
|
||||
onSubmit(expanded);
|
||||
}
|
||||
if (action.historyHandoff === "prev") onHistoryPrev?.();
|
||||
if (action.historyHandoff === "next") onHistoryNext?.();
|
||||
}, !disabled);
|
||||
|
||||
const ke: MultilineKey = {
|
||||
input,
|
||||
return: key.return,
|
||||
shift: key.shift,
|
||||
ctrl: key.ctrl,
|
||||
meta: key.meta,
|
||||
backspace: key.backspace,
|
||||
delete: key.delete,
|
||||
tab: key.tab,
|
||||
upArrow: key.upArrow,
|
||||
downArrow: key.downArrow,
|
||||
leftArrow: key.leftArrow,
|
||||
rightArrow: key.rightArrow,
|
||||
escape: key.escape,
|
||||
pageUp: key.pageUp,
|
||||
pageDown: key.pageDown,
|
||||
};
|
||||
const action = processMultilineKey(value, cursor, ke);
|
||||
if (action.pasteRequest) {
|
||||
// No bracketed-paste markers were seen — terminal doesn't
|
||||
// support DECSET 2004, or Ink's key parser ate them. Fall
|
||||
// back to per-chunk paste registration. Two chunks of the
|
||||
// same big paste land as two placeholders; correctness
|
||||
// preserved, only visual cohesion lost.
|
||||
registerPaste(action.pasteRequest.content);
|
||||
return;
|
||||
}
|
||||
if (action.next !== null) {
|
||||
lastLocalValueRef.current = action.next;
|
||||
onChange(action.next);
|
||||
}
|
||||
if (action.cursor !== null) {
|
||||
setCursor(action.cursor);
|
||||
}
|
||||
if (action.submit) {
|
||||
// Expand sentinels back to real paste content before handing
|
||||
// the prompt up to the parent. The buffer carries placeholders
|
||||
// for display; the model needs the full text.
|
||||
const raw = action.submitValue ?? value;
|
||||
const expanded = expandPasteSentinels(raw, pastesRef.current);
|
||||
// GC unreachable paste entries — anything whose sentinel was
|
||||
// backspace'd out of the buffer before submit. Keeps the
|
||||
// registry from growing unbounded across many turns.
|
||||
const reachable = new Set(listPasteIdsInBuffer(raw));
|
||||
for (const id of pastesRef.current.keys()) {
|
||||
if (!reachable.has(id)) pastesRef.current.delete(id);
|
||||
}
|
||||
onSubmit(expanded);
|
||||
}
|
||||
if (action.historyHandoff === "prev") onHistoryPrev?.();
|
||||
if (action.historyHandoff === "next") onHistoryNext?.();
|
||||
},
|
||||
{ isActive: !disabled },
|
||||
);
|
||||
// ── Render ──────────────────────────────────────────────────────
|
||||
|
||||
// Narrow-terminal mode: drop the `you ›` (6 col) prefix to a `›` (2
|
||||
// col) marker so the writable area on 80-col terminals reclaims 4
|
||||
// cols. Threshold of 90 keeps the friendly `you ›` on anything that
|
||||
// resembles a normal terminal; modern laptops tend to default to
|
||||
// 100+ cols, narrow ones (split panes, embedded shells) tend to be
|
||||
// <=90. Continuation indent shrinks proportionally so wrapped lines
|
||||
// still align under the prompt arrow.
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const narrow = cols <= 90;
|
||||
const promptPrefix = narrow ? "› " : "you › ";
|
||||
const continuationIndent = narrow ? " " : " ";
|
||||
const promptBody = narrow ? "› " : "you › ";
|
||||
const promptPrefix = BAR + promptBody;
|
||||
const continuationIndent = BAR + " ".repeat(promptBody.length);
|
||||
const prefixCells = promptPrefix.length;
|
||||
// Reserve 2 cells for the surrounding `paddingX={1}` plus 1 cell
|
||||
// for the cursor block when at end-of-line. Net visible budget per
|
||||
// line.
|
||||
const visibleCells = Math.max(8, cols - prefixCells - 3);
|
||||
|
||||
const placeholderActive = narrow
|
||||
? "type a message, or /command"
|
||||
: "type a message, or /command · [Shift+Enter] / [Ctrl+J] newline";
|
||||
: "type a message, or /command · [Ctrl+J] newline (Shift+Enter where supported)";
|
||||
const effectivePlaceholder = disabled
|
||||
? (placeholder ?? "…waiting for response…")
|
||||
: (placeholder ?? placeholderActive);
|
||||
|
||||
const lines = value.length > 0 ? value.split("\n") : [""];
|
||||
const borderColor = disabled ? "gray" : "cyan";
|
||||
const accentColor = disabled ? "gray" : "cyan";
|
||||
// Animation gate. Below ~100 cols a typed line plus prefix can
|
||||
// wrap; Ink's eraseLines miscounts on wrap and ghost rows pile up
|
||||
// every tick. Suppress the bar gradient + cursor blink when
|
||||
// narrow so the prompt renders statically — same fix the
|
||||
// StatsPanel uses on its rules.
|
||||
const animate = !disabled && cols >= 100;
|
||||
const tick = useTick();
|
||||
const barOffset = animate ? Math.floor(tick / 6) : 0;
|
||||
const barColorAt = (rowIdx: number): string =>
|
||||
disabled
|
||||
? "gray"
|
||||
: GRADIENT[(((rowIdx + barOffset) % GRADIENT.length) + GRADIENT.length) % GRADIENT.length]!;
|
||||
// Cursor blink — toggles every 4 ticks (~480 ms) so the prompt
|
||||
// looks alive at rest. Cursor stays solid while disabled (busy
|
||||
// turn) or narrow (anti-flash) so the screen reads as frozen,
|
||||
// not dead.
|
||||
const cursorVisible = animate ? Math.floor(tick / 4) % 2 === 0 : true;
|
||||
const { line: cursorLine, col: cursorCol } = lineAndColumn(value, cursor);
|
||||
// For large buffers (e.g. a big paste), rendering every line
|
||||
// re-runs Ink's layout + terminal write on every keystroke. Each
|
||||
// keystroke touching 500 rendered lines flickers visibly on most
|
||||
// terminals. Collapse to HEAD + cursor-line + TAIL with a dim
|
||||
// `[… N lines hidden …]` marker. Edit position is still anywhere;
|
||||
// the visible cursor line follows the cursor into/out of the
|
||||
// collapsed region so the user never loses sight of what they're
|
||||
// typing. Submitted value is the FULL buffer — collapse is purely
|
||||
// visual.
|
||||
|
||||
// Big-buffer mitigation: if the buffer has many logical lines,
|
||||
// collapse middle rows so Ink isn't redrawing 500+ rows per
|
||||
// keystroke.
|
||||
const renderItems = collapseLinesForDisplay(lines, cursorLine);
|
||||
// When the buffer is large enough to be collapsed, surface a dim
|
||||
// hint about the buffer-wide shortcuts. The user just pasted 500
|
||||
// lines; without this they have no idea PageUp/Ctrl+U exist and
|
||||
// ↑×500 is the only path back to the top.
|
||||
const showHugeBufferHints = lines.length > 20;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="column">
|
||||
{renderItems.map((item, renderIdx) => {
|
||||
if (item.kind === "skip") {
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable — skip markers are derived from a fixed-size window over `lines`
|
||||
<Box key={`skip-${renderIdx}`}>
|
||||
<Text dimColor>{continuationIndent}</Text>
|
||||
<Text
|
||||
dimColor
|
||||
>{`[… ${item.linesHidden} line${item.linesHidden === 1 ? "" : "s"} hidden — full content kept, submitted on Enter …]`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const line = item.line;
|
||||
const i = item.originalIndex;
|
||||
const isFirst = i === 0;
|
||||
const showPlaceholder = isFirst && value.length === 0;
|
||||
const isCursorLine = i === cursorLine;
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{renderItems.map((item, renderIdx) => {
|
||||
if (item.kind === "skip") {
|
||||
return (
|
||||
<Box key={`ln-${i}`}>
|
||||
{isFirst ? (
|
||||
<Text bold color={borderColor}>
|
||||
{promptPrefix}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>{continuationIndent}</Text>
|
||||
)}
|
||||
{showPlaceholder ? (
|
||||
<>
|
||||
{isCursorLine && !disabled ? (
|
||||
<Text color={borderColor}>{showCursor ? "▌" : " "}</Text>
|
||||
) : null}
|
||||
<Text dimColor>{effectivePlaceholder}</Text>
|
||||
</>
|
||||
) : isCursorLine && !disabled ? (
|
||||
<LineWithCursor
|
||||
line={line}
|
||||
col={cursorCol}
|
||||
showCursor={showCursor}
|
||||
borderColor={borderColor}
|
||||
pastes={pastesRef.current}
|
||||
/>
|
||||
) : (
|
||||
<RenderLine line={line} pastes={pastesRef.current} />
|
||||
)}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable — collapse markers derive from a fixed sliding window
|
||||
<Box key={`skip-${renderIdx}`}>
|
||||
<Text color={barColorAt(renderIdx)}>{BAR}</Text>
|
||||
<Text dimColor>{continuationIndent.slice(BAR.length)}</Text>
|
||||
<Text dimColor>
|
||||
{`[… ${item.linesHidden} line${item.linesHidden === 1 ? "" : "s"} hidden — full content kept, submitted on Enter …]`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{showHugeBufferHints && !disabled ? (
|
||||
<Box>
|
||||
<Text dimColor>{continuationIndent}</Text>
|
||||
<Text dimColor>
|
||||
{`[${lines.length} lines · PageUp/PageDown jump to top/bottom · Ctrl+U clear · Ctrl+W del word]`}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
}
|
||||
const i = item.originalIndex;
|
||||
const line = item.line;
|
||||
const isFirst = i === 0;
|
||||
const isCursorLine = i === cursorLine;
|
||||
const showPlaceholder = isFirst && value.length === 0;
|
||||
return (
|
||||
<PromptLine
|
||||
key={`ln-${i}`}
|
||||
line={line}
|
||||
isFirst={isFirst}
|
||||
isCursorLine={isCursorLine && !disabled}
|
||||
cursorCol={isCursorLine ? cursorCol : null}
|
||||
cursorVisible={cursorVisible}
|
||||
showPlaceholder={showPlaceholder}
|
||||
placeholderText={effectivePlaceholder}
|
||||
promptPrefix={promptPrefix}
|
||||
continuationIndent={continuationIndent}
|
||||
visibleCells={visibleCells}
|
||||
accentColor={accentColor}
|
||||
barColor={barColorAt(i)}
|
||||
pastes={pastesRef.current}
|
||||
disabled={disabled === true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showHugeBufferHints && !disabled ? (
|
||||
<Box>
|
||||
<Text color={barColorAt(0)}>{BAR}</Text>
|
||||
<Text dimColor>{continuationIndent.slice(BAR.length)}</Text>
|
||||
<Text dimColor>
|
||||
{`[${lines.length} lines · PageUp/PageDown jump to top/bottom · Ctrl+U clear · Ctrl+W del word]`}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{!disabled && !narrow && value.length > 0 && !value.includes("\n") ? (
|
||||
<Box>
|
||||
<Text color={barColorAt(0)}>{BAR}</Text>
|
||||
<Text dimColor>{continuationIndent.slice(BAR.length)}</Text>
|
||||
<Text dimColor>
|
||||
[Ctrl+J] newline · [Enter] submit · ends with \ for line continuation
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{disabled ? (
|
||||
<Box paddingX={1}>
|
||||
<Box>
|
||||
<Text color={barColorAt(0)}>{BAR}</Text>
|
||||
<Text dimColor>{continuationIndent.slice(BAR.length)}</Text>
|
||||
<Text dimColor>[Esc] to stop</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PromptLine ────────────────────────────────────────────────────
|
||||
|
||||
interface PromptLineProps {
|
||||
line: string;
|
||||
isFirst: boolean;
|
||||
isCursorLine: boolean;
|
||||
cursorCol: number | null;
|
||||
/** True when the cursor block / inverse char should be drawn this tick. */
|
||||
cursorVisible: boolean;
|
||||
showPlaceholder: boolean;
|
||||
placeholderText: string;
|
||||
promptPrefix: string;
|
||||
continuationIndent: string;
|
||||
visibleCells: number;
|
||||
accentColor: "cyan" | "gray";
|
||||
/** Animated gradient color for the leading ▎ bar. Differs per row + tick. */
|
||||
barColor: string;
|
||||
pastes: ReadonlyMap<number, PasteEntry>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function PromptLine({
|
||||
line,
|
||||
isFirst,
|
||||
isCursorLine,
|
||||
cursorCol,
|
||||
cursorVisible,
|
||||
showPlaceholder,
|
||||
placeholderText,
|
||||
promptPrefix,
|
||||
continuationIndent,
|
||||
visibleCells,
|
||||
accentColor,
|
||||
barColor,
|
||||
pastes,
|
||||
disabled,
|
||||
}: PromptLineProps) {
|
||||
// The leading BAR cells of every prefix/continuation are the left
|
||||
// anchor bar — render them as a separate span colored by the
|
||||
// animated gradient so the bar appears to flow vertically.
|
||||
const barText = promptPrefix.slice(0, BAR.length);
|
||||
const bodyPrefix = promptPrefix.slice(BAR.length);
|
||||
const bodyContinuation = continuationIndent.slice(BAR.length);
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={barColor}>{barText}</Text>
|
||||
<Text bold color={accentColor}>
|
||||
{bodyPrefix}
|
||||
</Text>
|
||||
{!disabled ? <Text color={accentColor}>{cursorVisible ? "▌" : " "}</Text> : null}
|
||||
<Text dimColor>{placeholderText}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const viewport = buildViewport(line, isCursorLine ? cursorCol : null, visibleCells, pastes);
|
||||
|
||||
// Render: prefix + (left marker?) + segments-with-cursor + (right marker?)
|
||||
return (
|
||||
<Box>
|
||||
<Text color={barColor}>{barText}</Text>
|
||||
{isFirst ? (
|
||||
<Text bold color={accentColor}>
|
||||
{bodyPrefix}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>{bodyContinuation}</Text>
|
||||
)}
|
||||
{viewport.hiddenLeft ? (
|
||||
<Text color="gray" dimColor>
|
||||
‹
|
||||
</Text>
|
||||
) : null}
|
||||
<ViewportContent
|
||||
segments={viewport.segments}
|
||||
cursorCell={isCursorLine ? viewport.cursorCell : null}
|
||||
accentColor={accentColor}
|
||||
cursorVisible={cursorVisible}
|
||||
/>
|
||||
{viewport.hiddenRight ? (
|
||||
<Text color="gray" dimColor>
|
||||
›
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ViewportContent ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One visual slot in the rendered prompt box. Either a real buffer
|
||||
* line (with its original index so the cursor / first-line prefix
|
||||
* still line up) or a "skip" marker summarizing hidden rows.
|
||||
* Render a viewport's segments with a cursor block at `cursorCell`.
|
||||
* Walks segments in order; the cursor splits at most one segment
|
||||
* (the one containing the cursor cell), producing an inverted char
|
||||
* at that position.
|
||||
*
|
||||
* End-of-line cursor (cursorCell points past the last cell of the
|
||||
* last segment): emit a trailing block.
|
||||
*/
|
||||
function ViewportContent({
|
||||
segments,
|
||||
cursorCell,
|
||||
accentColor,
|
||||
cursorVisible,
|
||||
}: {
|
||||
segments: Segment[];
|
||||
cursorCell: number | null;
|
||||
accentColor: "cyan" | "gray";
|
||||
cursorVisible: boolean;
|
||||
}) {
|
||||
// No cursor on this line — straight render.
|
||||
if (cursorCell === null) {
|
||||
return <>{segments.map((seg, i) => renderSegment(seg, i, false))}</>;
|
||||
}
|
||||
|
||||
// Walk segments tallying cells; once we reach `cursorCell`, split
|
||||
// the segment to insert the cursor block.
|
||||
const out: React.ReactNode[] = [];
|
||||
let cells = 0;
|
||||
let placed = false;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i]!;
|
||||
const segCells = segmentCells(seg);
|
||||
if (placed) {
|
||||
out.push(renderSegment(seg, i, false));
|
||||
continue;
|
||||
}
|
||||
if (cursorCell >= cells + segCells) {
|
||||
// Cursor isn't in this segment.
|
||||
out.push(renderSegment(seg, i, false));
|
||||
cells += segCells;
|
||||
continue;
|
||||
}
|
||||
// Cursor lands inside this segment.
|
||||
if (seg.kind === "paste") {
|
||||
// The cursor is "on" the paste sentinel — render the paste
|
||||
// block inversed so the user sees they're at it. Inverse
|
||||
// toggles with the blink so the paste sentinel stays
|
||||
// legible during the cursor's "off" half-cycle.
|
||||
out.push(
|
||||
<Text key={`p-${i}-cursor`} color="magenta" bold inverse={cursorVisible}>
|
||||
{seg.label}
|
||||
</Text>,
|
||||
);
|
||||
placed = true;
|
||||
cells += segCells;
|
||||
continue;
|
||||
}
|
||||
// text segment — split before/at-cursor/after by cell offset.
|
||||
const offsetIntoSeg = cursorCell - cells;
|
||||
const split = splitTextByCells(seg.text, offsetIntoSeg);
|
||||
if (split.before.length > 0) {
|
||||
out.push(<Text key={`t-${i}-b`}>{split.before}</Text>);
|
||||
}
|
||||
if (split.atCursor.length > 0) {
|
||||
out.push(
|
||||
<Text key={`t-${i}-c`} inverse={cursorVisible} color={accentColor}>
|
||||
{split.atCursor}
|
||||
</Text>,
|
||||
);
|
||||
} else {
|
||||
// Cursor sits past the segment's last char (end-of-text in this
|
||||
// segment). Render block here, blinking with the tick.
|
||||
out.push(
|
||||
<Text key={`t-${i}-c-eol`} color={accentColor}>
|
||||
{cursorVisible ? "▌" : " "}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
if (split.after.length > 0) {
|
||||
out.push(<Text key={`t-${i}-a`}>{split.after}</Text>);
|
||||
}
|
||||
placed = true;
|
||||
cells += segCells;
|
||||
}
|
||||
|
||||
// Cursor sits past every segment (end of line).
|
||||
if (!placed) {
|
||||
out.push(
|
||||
<Text key="cursor-eol" color={accentColor}>
|
||||
{cursorVisible ? "▌" : " "}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
return <>{out}</>;
|
||||
}
|
||||
|
||||
function segmentCells(seg: Segment): number {
|
||||
if (seg.kind === "paste") return seg.label.length;
|
||||
return stringCells(seg.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a text string at a cell offset, returning the chars before
|
||||
* the offset, the char AT the offset (the cursor block highlights
|
||||
* it), and the chars after. A wide char that straddles the offset
|
||||
* is treated as the cursor's char.
|
||||
*/
|
||||
function splitTextByCells(
|
||||
text: string,
|
||||
cellOffset: number,
|
||||
): { before: string; atCursor: string; after: string } {
|
||||
let cells = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
const cw = charCellsForText(ch);
|
||||
if (cells === cellOffset) {
|
||||
return { before: text.slice(0, i), atCursor: ch, after: text.slice(i + 1) };
|
||||
}
|
||||
if (cells + cw > cellOffset) {
|
||||
// The wide char straddles the offset — show it as the cursor char.
|
||||
return { before: text.slice(0, i), atCursor: ch, after: text.slice(i + 1) };
|
||||
}
|
||||
cells += cw;
|
||||
}
|
||||
// Cursor at end of text.
|
||||
return { before: text, atCursor: "", after: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Local cell-counting helper — duplicates `charCells` from
|
||||
* prompt-viewport but inlined to avoid a back-and-forth import (this
|
||||
* function is hot per-keystroke). Keep in sync.
|
||||
*/
|
||||
function charCellsForText(ch: string): number {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code < 0x20 || code === 0x7f) return 0;
|
||||
if (code < 0x1100) return 1;
|
||||
if (code >= 0x1100 && code <= 0x115f) return 2;
|
||||
if (code >= 0x2e80 && code <= 0x303e) return 2;
|
||||
if (code >= 0x3041 && code <= 0x33ff) return 2;
|
||||
if (code >= 0x3400 && code <= 0x4dbf) return 2;
|
||||
if (code >= 0x4e00 && code <= 0x9fff) return 2;
|
||||
if (code >= 0xa000 && code <= 0xa4cf) return 2;
|
||||
if (code >= 0xac00 && code <= 0xd7a3) return 2;
|
||||
if (code >= 0xf900 && code <= 0xfaff) return 2;
|
||||
if (code >= 0xfe30 && code <= 0xfe4f) return 2;
|
||||
if (code >= 0xff00 && code <= 0xff60) return 2;
|
||||
if (code >= 0xffe0 && code <= 0xffe6) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function renderSegment(seg: Segment, key: number, _inverse: boolean): React.ReactNode {
|
||||
if (seg.kind === "text") {
|
||||
return <Text key={`s-${key}`}>{seg.text}</Text>;
|
||||
}
|
||||
// Paste sentinels render as a fuchsia bg-pill so they're visually
|
||||
// distinct from typed text — the user can see "this is the
|
||||
// collapsed paste, not 47 lines of code I typed". Matches the
|
||||
// bg-pill idiom used elsewhere (mode bars, tool names).
|
||||
return (
|
||||
<Text key={`s-${key}`} backgroundColor="#f0abfc" color="black" bold>
|
||||
{seg.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// ── collapse helper (preserved from v1) ────────────────────────────
|
||||
|
||||
type RenderItem =
|
||||
| { kind: "line"; line: string; originalIndex: number }
|
||||
| { kind: "skip"; linesHidden: number };
|
||||
|
||||
/**
|
||||
* Above this line count we stop rendering every line on every
|
||||
* keystroke — a 500-line paste would otherwise make Ink redraw
|
||||
* hundreds of rows per character typed, which is visibly janky.
|
||||
* Chosen empirically: under 20 lines the user is still visually
|
||||
* scanning the buffer; above that they're almost always looking
|
||||
* at a pasted blob that should be summarized.
|
||||
*/
|
||||
const COLLAPSE_THRESHOLD = 20;
|
||||
const COLLAPSE_HEAD_LINES = 3;
|
||||
const COLLAPSE_TAIL_LINES = 2;
|
||||
@@ -405,9 +551,6 @@ export function collapseLinesForDisplay(lines: string[], cursorLine: number): Re
|
||||
if (lines.length <= COLLAPSE_THRESHOLD) {
|
||||
return lines.map((line, i) => ({ kind: "line" as const, line, originalIndex: i }));
|
||||
}
|
||||
// Always show the first HEAD lines, the last TAIL lines, and
|
||||
// the cursor line if it falls in the collapsed middle. Union
|
||||
// the indices so head/cursor/tail overlap collapses cleanly.
|
||||
const keep = new Set<number>();
|
||||
for (let i = 0; i < COLLAPSE_HEAD_LINES && i < lines.length; i++) keep.add(i);
|
||||
for (let i = Math.max(0, lines.length - COLLAPSE_TAIL_LINES); i < lines.length; i++) keep.add(i);
|
||||
@@ -424,100 +567,3 @@ export function collapseLinesForDisplay(lines: string[], cursorLine: number): Re
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a buffer line with paste sentinels expanded into magenta
|
||||
* `[paste #N · …]` placeholder blocks. Used for non-cursor lines and
|
||||
* for the prefix / suffix segments around the cursor on the cursor
|
||||
* line.
|
||||
*
|
||||
* Each codepoint is emitted as either:
|
||||
* - text: accumulated in `buf`, flushed when a sentinel or the end
|
||||
* of the line is reached.
|
||||
* - placeholder block: a styled `[paste #N · 3337l · 184KB]` Text.
|
||||
*
|
||||
* No layout magic — the emitted segments sit inline in the parent
|
||||
* Box's row, just like plain text.
|
||||
*/
|
||||
function RenderLine({
|
||||
line,
|
||||
pastes,
|
||||
inverse,
|
||||
}: {
|
||||
line: string;
|
||||
pastes: ReadonlyMap<number, PasteEntry>;
|
||||
inverse?: boolean;
|
||||
}) {
|
||||
const segments: React.ReactNode[] = [];
|
||||
let buf = "";
|
||||
let segIdx = 0;
|
||||
const flushBuf = () => {
|
||||
if (buf.length === 0) return;
|
||||
segments.push(
|
||||
<Text key={`t-${segIdx++}`} inverse={inverse}>
|
||||
{buf}
|
||||
</Text>,
|
||||
);
|
||||
buf = "";
|
||||
};
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]!;
|
||||
const id = decodePasteSentinel(ch);
|
||||
if (id === null) {
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
flushBuf();
|
||||
const entry = pastes.get(id);
|
||||
const label = entry
|
||||
? `[paste #${id + 1} · ${entry.lineCount}l · ${formatBytesShort(entry.charCount)}]`
|
||||
: `[paste #${id + 1} · (missing)]`;
|
||||
segments.push(
|
||||
<Text key={`p-${segIdx++}`} color="magenta" bold inverse={inverse}>
|
||||
{label}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
flushBuf();
|
||||
if (segments.length === 0) {
|
||||
// Empty line — return a single empty Text so Ink still renders a
|
||||
// row. Otherwise this Box collapses to zero height.
|
||||
return <Text> </Text>;
|
||||
}
|
||||
return <>{segments}</>;
|
||||
}
|
||||
|
||||
function LineWithCursor({
|
||||
line,
|
||||
col,
|
||||
showCursor,
|
||||
borderColor,
|
||||
pastes,
|
||||
}: {
|
||||
line: string;
|
||||
col: number;
|
||||
showCursor: boolean;
|
||||
borderColor: "cyan" | "gray";
|
||||
pastes: ReadonlyMap<number, PasteEntry>;
|
||||
}) {
|
||||
const before = line.slice(0, col);
|
||||
const atCursor = line.slice(col, col + 1);
|
||||
const after = line.slice(col + 1);
|
||||
if (atCursor.length === 0) {
|
||||
// Cursor sits past the last char of this line (end-of-line). Render
|
||||
// a trailing block so the user sees where they're typing next.
|
||||
return (
|
||||
<>
|
||||
<RenderLine line={before} pastes={pastes} />
|
||||
<Text color={borderColor}>{showCursor ? "▌" : " "}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<RenderLine line={before} pastes={pastes} />
|
||||
<RenderLine line={atCursor} pastes={pastes} inverse={showCursor} />
|
||||
<RenderLine line={after} pastes={pastes} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+15
-12
@@ -9,8 +9,9 @@
|
||||
* rendering quirk bundled (`ink-text-input`); adding more is low value.
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
|
||||
export interface SelectItem<V extends string = string> {
|
||||
/** Stable identifier — returned to caller on submit. */
|
||||
@@ -50,15 +51,16 @@ export function SingleSelect<V extends string>({
|
||||
);
|
||||
const [index, setIndex] = useState(initialIndex === -1 ? 0 : initialIndex);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.upArrow) {
|
||||
useKeystroke((ev) => {
|
||||
if (ev.paste) return;
|
||||
if (ev.upArrow) {
|
||||
setIndex((i) => findNextEnabled(items, i, -1));
|
||||
} else if (key.downArrow) {
|
||||
} else if (ev.downArrow) {
|
||||
setIndex((i) => findNextEnabled(items, i, +1));
|
||||
} else if (key.return) {
|
||||
} else if (ev.return) {
|
||||
const chosen = items[index];
|
||||
if (chosen && !chosen.disabled) onSubmit(chosen.value);
|
||||
} else if (key.escape && onCancel) {
|
||||
} else if (ev.escape && onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
@@ -104,12 +106,13 @@ export function MultiSelect<V extends string>({
|
||||
});
|
||||
const [selected, setSelected] = useState<Set<V>>(new Set(initialSelected));
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.upArrow) {
|
||||
useKeystroke((ev) => {
|
||||
if (ev.paste) return;
|
||||
if (ev.upArrow) {
|
||||
setIndex((i) => findNextEnabled(items, i, -1));
|
||||
} else if (key.downArrow) {
|
||||
} else if (ev.downArrow) {
|
||||
setIndex((i) => findNextEnabled(items, i, +1));
|
||||
} else if (input === " ") {
|
||||
} else if (ev.input === " ") {
|
||||
const item = items[index];
|
||||
if (!item || item.disabled) return;
|
||||
setSelected((prev) => {
|
||||
@@ -118,13 +121,13 @@ export function MultiSelect<V extends string>({
|
||||
else next.add(item.value);
|
||||
return next;
|
||||
});
|
||||
} else if (key.return) {
|
||||
} else if (ev.return) {
|
||||
// Preserve catalog order rather than insertion order, so reruns
|
||||
// produce the same spec list for the same checkbox set — makes the
|
||||
// `config.json` diff trivially stable.
|
||||
const ordered = items.filter((i) => selected.has(i.value)).map((i) => i.value);
|
||||
onSubmit(ordered);
|
||||
} else if (key.escape && onCancel) {
|
||||
} else if (ev.escape && onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
+38
-50
@@ -1,5 +1,6 @@
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import { ModalCard } from "./ModalCard.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
|
||||
export type ShellConfirmChoice = "run_once" | "always_allow" | "deny";
|
||||
@@ -34,59 +35,46 @@ export interface ShellConfirmProps {
|
||||
*/
|
||||
export function ShellConfirm({ command, allowPrefix, kind, onChoose }: ShellConfirmProps) {
|
||||
const isBackground = kind === "run_background";
|
||||
const subtitle = isBackground
|
||||
? "long-running process — keeps running after approval, /kill to stop"
|
||||
: "model wants to run a shell command";
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1} marginY={1}>
|
||||
<Box>
|
||||
<Text bold color="red">
|
||||
{isBackground
|
||||
? "▸ model wants to start a BACKGROUND process"
|
||||
: "▸ model wants to run a shell command"}
|
||||
<ModalCard
|
||||
accent="#f87171"
|
||||
icon={isBackground ? "⏱" : "⚡"}
|
||||
title={isBackground ? "background process" : "shell command"}
|
||||
subtitle={subtitle}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>{"$ "}</Text>
|
||||
<Text color="#67e8f9" bold>
|
||||
{command}
|
||||
</Text>
|
||||
</Box>
|
||||
{isBackground ? (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{" (long-running: dev server / watcher; keeps running after approval, /kill to stop)"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Text color="red" dimColor>
|
||||
{"──────────────────────────────────────────"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Text dimColor>{"$ "}</Text>
|
||||
<Text color="cyan">{command}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<SingleSelect
|
||||
initialValue="run_once"
|
||||
items={[
|
||||
{
|
||||
value: "run_once",
|
||||
label: "Run once",
|
||||
hint: "Execute this command, don't remember it.",
|
||||
},
|
||||
{
|
||||
value: "always_allow",
|
||||
label: `Always allow "${allowPrefix}" in this project`,
|
||||
hint: "Save the prefix to ~/.reasonix/config.json; future matches auto-run.",
|
||||
},
|
||||
{
|
||||
value: "deny",
|
||||
label: "Deny",
|
||||
hint: "Tell the model the user refused; it will continue without this command.",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as ShellConfirmChoice)}
|
||||
onCancel={() => onChoose("deny")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] deny"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<SingleSelect
|
||||
initialValue="run_once"
|
||||
items={[
|
||||
{
|
||||
value: "run_once",
|
||||
label: "Run once",
|
||||
hint: "Execute this command, don't remember it.",
|
||||
},
|
||||
{
|
||||
value: "always_allow",
|
||||
label: `Always allow "${allowPrefix}" in this project`,
|
||||
hint: "Save the prefix to ~/.reasonix/config.json; future matches auto-run.",
|
||||
},
|
||||
{
|
||||
value: "deny",
|
||||
label: "Deny",
|
||||
hint: "Tell the model the user refused; it will continue without this command.",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as ShellConfirmChoice)}
|
||||
onCancel={() => onChoose("deny")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] deny"
|
||||
/>
|
||||
</ModalCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,29 +57,23 @@ export function SlashSuggestions({
|
||||
}
|
||||
|
||||
function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelected: boolean }) {
|
||||
const marker = isSelected ? "▸" : " ";
|
||||
const name = `/${spec.cmd}`;
|
||||
const argsSuffix = spec.argsHint ? ` ${spec.argsHint}` : "";
|
||||
// Selected row gets full cyan coloring so it pops out against the
|
||||
// dimmed others — mirrors the style GitHub Issues / Discord use
|
||||
// for focused dropdown rows.
|
||||
// Selected row gets a solid-bg highlight (cyan) so it pops like
|
||||
// an editor's focused autocomplete entry. Non-selected rows stay
|
||||
// dim with a leading bullet for vertical rhythm.
|
||||
if (isSelected) {
|
||||
return (
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
{marker} {name.padEnd(12)}
|
||||
{argsSuffix.padEnd(16)}
|
||||
<Text backgroundColor="#67e8f9" color="black" bold>
|
||||
{` ▸ ${name.padEnd(12)}${argsSuffix.padEnd(16)} ${spec.summary} `}
|
||||
</Text>
|
||||
<Text color="cyan"> {spec.summary}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{marker} {name.padEnd(12)}
|
||||
{argsSuffix.padEnd(16)} {spec.summary}
|
||||
</Text>
|
||||
<Text color="#94a3b8">{` ${name.padEnd(12)}${argsSuffix.padEnd(16)} ${spec.summary}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+242
-118
@@ -4,47 +4,47 @@ import type { EditMode } from "../../config.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS } from "../../telemetry.js";
|
||||
import type { SessionSummary } from "../../telemetry.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import { COLOR, GRADIENT } from "./theme.js";
|
||||
import { useTick } from "./ticker.js";
|
||||
|
||||
/**
|
||||
* Cyan → blue → purple truecolor gradient for the REASONIX wordmark.
|
||||
* Each char gets its own hex color — terminals that support 24-bit
|
||||
* color (nearly all since 2016) render this as a smooth fade. Older
|
||||
* 256-color terminals snap to nearest; 8-color fall all the way back
|
||||
* to plain cyan via Ink's color pipeline, so the worst case is still
|
||||
* legible.
|
||||
*/
|
||||
const WORDMARK_STYLES: ReadonlyArray<{ ch: string; color: string; isLogo: boolean }> = [
|
||||
{ ch: "◈", color: "#5eead4", isLogo: true }, // teal — brand mark
|
||||
{ ch: " ", color: "#5eead4", isLogo: false },
|
||||
{ ch: "R", color: "#67e8f9", isLogo: false }, // cyan
|
||||
{ ch: "E", color: "#7dd3fc", isLogo: false }, // sky
|
||||
{ ch: "A", color: "#93c5fd", isLogo: false }, // blue
|
||||
{ ch: "S", color: "#a5b4fc", isLogo: false }, // indigo
|
||||
{ ch: "O", color: "#c4b5fd", isLogo: false }, // violet
|
||||
{ ch: "N", color: "#d8b4fe", isLogo: false }, // purple
|
||||
{ ch: "I", color: "#f0abfc", isLogo: false }, // fuchsia
|
||||
{ ch: "X", color: "#f0abfc", isLogo: false }, // fuchsia
|
||||
];
|
||||
const WORDMARK_LETTERS: ReadonlyArray<string> = ["R", "E", "A", "S", "O", "N", "I", "X"];
|
||||
|
||||
/**
|
||||
* Gradient-filled, animated app wordmark. The `◈` brand mark breathes
|
||||
* (bold on/off) once every ~1.6s when idle, ~600ms when the model is
|
||||
* working, so a glance at the top of the screen tells you immediately
|
||||
* whether the app is doing something. Letters' keys use the char+color
|
||||
* pair (not an index) so React state attaches to the glyph itself;
|
||||
* fine because WORDMARK_STYLES never reorders.
|
||||
* Gradient-flowing wordmark. Every tick the gradient shifts one
|
||||
* position so the colors visibly travel across the letters — like
|
||||
* a neon sign. Brand mark `◈` rides the same flow but also pulses
|
||||
* bold on/off (slow when idle, fast when busy) so a glance at the
|
||||
* top of the screen tells you whether the app is thinking.
|
||||
*
|
||||
* Tick is ~120 ms. `rotateEvery=4` ticks → one shift every ~480 ms
|
||||
* idle, ~240 ms when busy. Slow enough to be ambient, fast enough
|
||||
* to feel alive. Truecolor terminals get the smooth flow; 8-color
|
||||
* fallbacks just see all-cyan, which is still legible.
|
||||
*/
|
||||
function Wordmark({ busy }: { busy: boolean }) {
|
||||
function Wordmark({ busy, animate }: { busy: boolean; animate: boolean }) {
|
||||
const tick = useTick();
|
||||
// Slow pulse = 12 ticks (~1.4s). Busy pulse = 5 ticks (~600ms).
|
||||
const period = busy ? 5 : 12;
|
||||
const bright = Math.floor(tick / period) % 2 === 0;
|
||||
// Bold pulse on the brand mark — kept even when animations are
|
||||
// suppressed because it's just a single-cell bold toggle, no
|
||||
// re-rendered text width that could wrap.
|
||||
const pulsePeriod = busy ? 5 : 12;
|
||||
const bright = animate ? Math.floor(tick / pulsePeriod) % 2 === 0 : true;
|
||||
// Gradient flow. Higher value = slower flow. When `animate` is
|
||||
// false (narrow terminal — wrapping risk) we freeze the offset
|
||||
// at 0 so re-renders don't pile up ghost rows from miscounted
|
||||
// erases.
|
||||
const rotateEvery = busy ? 2 : 4;
|
||||
const offset = animate ? Math.floor(tick / rotateEvery) : 0;
|
||||
const colorAt = (i: number) =>
|
||||
GRADIENT[(((i + offset) % GRADIENT.length) + GRADIENT.length) % GRADIENT.length]!;
|
||||
return (
|
||||
<Text>
|
||||
{WORDMARK_STYLES.map((c) => (
|
||||
<Text key={`${c.ch}-${c.color}`} color={c.color} bold={c.isLogo ? bright : true}>
|
||||
{c.ch}
|
||||
<Text color={colorAt(0)} bold={bright}>
|
||||
◈
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
{WORDMARK_LETTERS.map((letter, i) => (
|
||||
<Text key={letter} color={colorAt(i + 1)} bold>
|
||||
{letter}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
@@ -158,33 +158,47 @@ export function StatsPanel({
|
||||
// actually had a chance to build, we flip to the live gradient.
|
||||
const coldStart = summary.turns <= COLD_START_TURNS;
|
||||
|
||||
// The two decorative gradient rules (top + bottom) frame the panel
|
||||
// as one visual unit instead of leaving the wordmark floating in
|
||||
// the void. Width tracks the terminal so the bar always stretches
|
||||
// edge-to-edge; falls back to 78 cells in tests / no-stdout.
|
||||
const ruleWidth = Math.max(20, columns - 2);
|
||||
// Animation gate. Re-rendering every tick is fine when nothing in
|
||||
// the live region wraps — Ink's eraseLines counts visible rows
|
||||
// accurately. But at narrow widths the header / rules / pills
|
||||
// wrap, Ink miscounts, and ghost rows pile up on every tick = the
|
||||
// "screen flashing" symptom users see. We suppress all live-
|
||||
// region animation below a safe threshold so static rendering can
|
||||
// still look good without the churn.
|
||||
const animate = columns >= 100;
|
||||
return (
|
||||
// Explicit `width={columns}` pins the border frame to the exact
|
||||
// terminal width. Without this, Ink auto-flexes the Box to
|
||||
// container width, and on terminal resize the prior frame's
|
||||
// wrapped-overflow can leave tails in the scrollback (each
|
||||
// redraw stacks a slightly-wider-or-narrower frame). Fixing
|
||||
// width per-render doesn't eliminate the underlying Ink
|
||||
// limitation (eraseLines counts logical rows, not post-wrap
|
||||
// display rows) but makes each frame's dimensions exact so
|
||||
// there's no residual uncertainty in the erase.
|
||||
<Box borderStyle="round" borderColor="cyan" flexDirection="column" paddingX={1} width={columns}>
|
||||
<Header
|
||||
model={model}
|
||||
prefixHash={prefixHash}
|
||||
harvestOn={harvestOn}
|
||||
branchOn={branchOn}
|
||||
branchBudget={branchBudget ?? 1}
|
||||
reasoningEffort={reasoningEffort}
|
||||
planMode={planMode}
|
||||
editMode={editMode}
|
||||
turns={summary.turns}
|
||||
updateAvailable={updateAvailable}
|
||||
narrow={narrow}
|
||||
busy={busy ?? false}
|
||||
proArmed={proArmed ?? false}
|
||||
escalated={escalated ?? false}
|
||||
/>
|
||||
// Borderless layout: no `borderStyle`, no rounded box. Bordered
|
||||
// Boxes were the most visible amplifier of Ink's eraseLines
|
||||
// miscount on Windows terminals. Visual weight here comes from
|
||||
// truecolor gradient rules at the top and bottom (rendered as
|
||||
// pure Text so they never trigger the eraseLines bug), the
|
||||
// animated wordmark + pill row, and a soft inner padding.
|
||||
<Box flexDirection="column" paddingX={1} marginBottom={1}>
|
||||
<GradientRule width={ruleWidth} animate={animate} />
|
||||
<Box marginTop={1}>
|
||||
<Header
|
||||
model={model}
|
||||
prefixHash={prefixHash}
|
||||
harvestOn={harvestOn}
|
||||
branchOn={branchOn}
|
||||
branchBudget={branchBudget ?? 1}
|
||||
reasoningEffort={reasoningEffort}
|
||||
planMode={planMode}
|
||||
editMode={editMode}
|
||||
turns={summary.turns}
|
||||
updateAvailable={updateAvailable}
|
||||
narrow={narrow}
|
||||
busy={busy ?? false}
|
||||
proArmed={proArmed ?? false}
|
||||
escalated={escalated ?? false}
|
||||
animate={animate}
|
||||
/>
|
||||
</Box>
|
||||
{narrow ? (
|
||||
<StackedMetrics
|
||||
summary={summary}
|
||||
@@ -202,6 +216,51 @@ export function StatsPanel({
|
||||
coldStart={coldStart}
|
||||
/>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<GradientRule width={ruleWidth} thin animate={animate} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line gradient bar spanning the panel width. `thin` swaps the
|
||||
* solid block for a half-block so the bottom of the panel is
|
||||
* visually quieter than the top — top reads as "header band", bottom
|
||||
* reads as "section close." Colors flow over time (one cell every
|
||||
* ~720 ms) so the panel feels alive without being twitchy. Each
|
||||
* cell renders as its own Text so Ink's runtime can color each
|
||||
* character independently; this is the pattern the wordmark uses
|
||||
* too.
|
||||
*/
|
||||
function GradientRule({
|
||||
width,
|
||||
thin,
|
||||
animate,
|
||||
}: {
|
||||
width: number;
|
||||
thin?: boolean;
|
||||
animate: boolean;
|
||||
}) {
|
||||
const tick = useTick();
|
||||
// When animation is suppressed (narrow terminal) freeze offset
|
||||
// so the bar still gets the gradient sweep but doesn't re-render.
|
||||
const offset = animate ? Math.floor(tick / 6) : 0;
|
||||
const ch = thin ? "▁" : "▄";
|
||||
const len = GRADIENT.length;
|
||||
return (
|
||||
<Box>
|
||||
{Array.from({ length: width }, (_, i) => {
|
||||
const t = width === 1 ? 0 : (i * (len - 1)) / (width - 1);
|
||||
const idx = (Math.round(t) + offset) % len;
|
||||
const color = GRADIENT[((idx % len) + len) % len]!;
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-width gradient cells never reorder
|
||||
<Text key={`grule-${i}`} color={color}>
|
||||
{ch}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -221,6 +280,7 @@ function Header({
|
||||
busy,
|
||||
proArmed,
|
||||
escalated,
|
||||
animate,
|
||||
}: {
|
||||
model: string;
|
||||
prefixHash: string;
|
||||
@@ -236,54 +296,96 @@ function Header({
|
||||
busy: boolean;
|
||||
proArmed: boolean;
|
||||
escalated: boolean;
|
||||
/** When false, suppress optional pills + animation to avoid wraps. */
|
||||
animate: boolean;
|
||||
}) {
|
||||
// Mode pill — pick the most informative one to surface in the
|
||||
// header. PLAN beats AUTO/review beats nothing. Pro armed/escalated
|
||||
// gets its own pill alongside.
|
||||
const modePill = planMode
|
||||
? { label: "PLAN", bg: "red" as const }
|
||||
: editMode === "auto"
|
||||
? { label: "AUTO", bg: "magenta" as const }
|
||||
: editMode === "review"
|
||||
? { label: "REVIEW", bg: "cyan" as const }
|
||||
: null;
|
||||
const proPill = escalated
|
||||
? { label: "⇧ PRO", bg: "red" as const }
|
||||
: proArmed
|
||||
? { label: "⇧ PRO", bg: "yellow" as const }
|
||||
: null;
|
||||
// Narrow / non-animate mode hides the secondary pills (harvest,
|
||||
// branch, reasoning effort) so the header always fits on one
|
||||
// visual row. Wrapping the header is the main amplifier of the
|
||||
// eraseLines miscount that produces the "screen flashing" effect.
|
||||
const showSecondary = animate && !narrow;
|
||||
return (
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Wordmark busy={busy} />
|
||||
<Text dimColor>{` v${VERSION}`}</Text>
|
||||
<Text dimColor> · </Text>
|
||||
<Text color="yellow">{model}</Text>
|
||||
{narrow ? null : (
|
||||
<Wordmark busy={busy} animate={animate} />
|
||||
<Text dimColor>{` ${VERSION}`}</Text>
|
||||
<Text dimColor>{" "}</Text>
|
||||
<Text color="yellow" bold>
|
||||
{model.replace(/^deepseek-/, "")}
|
||||
</Text>
|
||||
{modePill ? (
|
||||
<>
|
||||
<Text dimColor> · </Text>
|
||||
<Text dimColor>{prefixHash}</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Pill label={modePill.label} bg={modePill.bg} />
|
||||
</>
|
||||
)}
|
||||
{harvestOn ? <Text color="magenta"> · harvest</Text> : null}
|
||||
{branchOn ? <Text color="blue"> · branch{branchBudget}</Text> : null}
|
||||
{reasoningEffort === "max" ? <Text color="green"> · max</Text> : null}
|
||||
{reasoningEffort === "high" ? <Text color="yellow"> · high</Text> : null}
|
||||
{planMode ? (
|
||||
<Text color="red" bold>
|
||||
{" · PLAN"}
|
||||
) : null}
|
||||
{proPill ? (
|
||||
<>
|
||||
<Text>{" "}</Text>
|
||||
<Pill label={proPill.label} bg={proPill.bg} />
|
||||
</>
|
||||
) : null}
|
||||
{showSecondary && harvestOn ? (
|
||||
<Text dimColor>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="magenta">harvest</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{editMode ? (
|
||||
<Text color={editMode === "auto" ? "magenta" : "cyan"} bold>
|
||||
{editMode === "auto" ? " · AUTO" : " · review"}
|
||||
{showSecondary && branchOn ? (
|
||||
<Text dimColor>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="blue">{`branch×${branchBudget}`}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{escalated ? (
|
||||
<Text color="red" bold>
|
||||
{" · ⇧ pro escalated"}
|
||||
</Text>
|
||||
) : proArmed ? (
|
||||
<Text color="yellow" bold>
|
||||
{" · ⇧ pro armed"}
|
||||
</Text>
|
||||
{showSecondary && reasoningEffort === "max" ? (
|
||||
<>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="green" dimColor>
|
||||
max
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
{showSecondary && reasoningEffort === "high" ? (
|
||||
<>
|
||||
<Text>{" "}</Text>
|
||||
<Text color="yellow" dimColor>
|
||||
high
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<Text>
|
||||
{updateAvailable ? (
|
||||
<Text color="yellow" bold>{`update: ${updateAvailable} · `}</Text>
|
||||
) : null}
|
||||
<Text dimColor>{narrow ? `turn ${turns}` : `turn ${turns} · /help`}</Text>
|
||||
{updateAvailable ? <Text color="yellow" bold>{`↑ ${updateAvailable} `}</Text> : null}
|
||||
<Text dimColor>{narrow ? `t${turns}` : `turn ${turns} · /help`}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Solid-background tag, used for primary mode + pro indicators. */
|
||||
function Pill({ label, bg }: { label: string; bg: "red" | "magenta" | "cyan" | "yellow" }) {
|
||||
return (
|
||||
<Text backgroundColor={bg} color="white" bold>
|
||||
{` ${label} `}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wide-terminal layout (≥120 cols): single metrics row. Matches the
|
||||
* original panel, just with calmer spacing and the cold-start
|
||||
@@ -360,24 +462,26 @@ function ContextCell({
|
||||
if (promptTokens === 0) {
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>ctx </Text>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{"▣ ctx "}
|
||||
</Text>
|
||||
<Text dimColor>— (no turns yet)</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const color = ratio >= 0.8 ? "red" : ratio >= 0.6 ? "yellow" : "green";
|
||||
const color = ratio >= 0.8 ? COLOR.err : ratio >= 0.6 ? COLOR.warn : COLOR.ok;
|
||||
const pct = Math.round(ratio * 100);
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>ctx </Text>
|
||||
{showBar ? <Bar ratio={ratio} color={color} /> : null}
|
||||
{showBar ? <Text> </Text> : null}
|
||||
<Text color={COLOR.info}>{"▣ ctx "}</Text>
|
||||
<Bar ratio={ratio} color={color} cells={showBar ? 14 : 10} />
|
||||
<Text> </Text>
|
||||
<Text color={color} bold>
|
||||
{formatTokens(promptTokens)}/{formatTokens(ctxMax)}
|
||||
</Text>
|
||||
<Text dimColor> ({pct}%)</Text>
|
||||
{ratio >= 0.8 ? (
|
||||
<Text color="red" bold>
|
||||
<Text color={COLOR.err} bold>
|
||||
{" · /compact"}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -398,7 +502,9 @@ function CacheCell({
|
||||
if (turns === 0) {
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cache </Text>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{"⌬ cache "}
|
||||
</Text>
|
||||
<Text dimColor>—</Text>
|
||||
</Text>
|
||||
);
|
||||
@@ -406,7 +512,9 @@ function CacheCell({
|
||||
if (coldStart) {
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cache </Text>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{"⌬ cache "}
|
||||
</Text>
|
||||
<Text dimColor>{pct}% </Text>
|
||||
<Text dimColor italic>
|
||||
(cold start)
|
||||
@@ -414,10 +522,10 @@ function CacheCell({
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const color = hitRatio >= 0.7 ? "green" : hitRatio >= 0.4 ? "yellow" : "red";
|
||||
const color = hitRatio >= 0.7 ? COLOR.ok : hitRatio >= 0.4 ? COLOR.warn : COLOR.err;
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cache </Text>
|
||||
<Text color={COLOR.info}>{"⌬ cache "}</Text>
|
||||
<Text color={color} bold>
|
||||
{pct}%
|
||||
</Text>
|
||||
@@ -430,17 +538,17 @@ function CacheCell({
|
||||
* apart — a $0.20 single turn should feel just as warn-worthy as a $2
|
||||
* session total. Values are rough "notice this" points, not hard caps.
|
||||
*/
|
||||
function turnCostColor(cost: number): "green" | "yellow" | "red" | undefined {
|
||||
function turnCostColor(cost: number): string | undefined {
|
||||
if (cost <= 0) return undefined;
|
||||
if (cost >= 0.2) return "red";
|
||||
if (cost >= 0.05) return "yellow";
|
||||
return "green";
|
||||
if (cost >= 0.2) return COLOR.err;
|
||||
if (cost >= 0.05) return COLOR.warn;
|
||||
return COLOR.ok;
|
||||
}
|
||||
function sessionCostColor(cost: number): "green" | "yellow" | "red" | undefined {
|
||||
function sessionCostColor(cost: number): string | undefined {
|
||||
if (cost <= 0) return undefined;
|
||||
if (cost >= 5) return "red";
|
||||
if (cost >= 0.5) return "yellow";
|
||||
return "green";
|
||||
if (cost >= 5) return COLOR.err;
|
||||
if (cost >= 0.5) return COLOR.warn;
|
||||
return COLOR.ok;
|
||||
}
|
||||
|
||||
function CostCell({
|
||||
@@ -453,7 +561,9 @@ function CostCell({
|
||||
if (summary.turns === 0) {
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cost </Text>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{"◴ cost "}
|
||||
</Text>
|
||||
<Text dimColor>—</Text>
|
||||
</Text>
|
||||
);
|
||||
@@ -466,7 +576,7 @@ function CostCell({
|
||||
const sessionColor = coldStart ? undefined : sessionCostColor(summary.totalCostUsd);
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>turn </Text>
|
||||
<Text color={COLOR.info}>{"◴ turn "}</Text>
|
||||
<Text color={turnColor} bold={!coldStart} dimColor={coldStart}>
|
||||
${summary.lastTurnCostUsd.toFixed(4)}
|
||||
</Text>
|
||||
@@ -479,10 +589,10 @@ function CostCell({
|
||||
}
|
||||
|
||||
function BalanceCell({ balance }: { balance: { currency: string; total: number } }) {
|
||||
const color = balance.total < 1 ? "red" : balance.total < 5 ? "yellow" : "green";
|
||||
const color = balance.total < 1 ? COLOR.err : balance.total < 5 ? COLOR.warn : COLOR.ok;
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>balance </Text>
|
||||
<Text color={COLOR.info}>{"◐ balance "}</Text>
|
||||
<Text color={color} bold>
|
||||
{balance.currency === "USD" ? "$" : ""}
|
||||
{balance.total.toFixed(2)}
|
||||
@@ -493,15 +603,29 @@ function BalanceCell({ balance }: { balance: { currency: string; total: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Unicode progress bar for the narrow layout's context cell. 10 cells
|
||||
* wide — narrow terminals are already cramped, and the bar is just a
|
||||
* visual cue for the percentage next to it, not the primary readout.
|
||||
* Truecolor progress bar. Filled cells use the threshold color (green
|
||||
* → amber → rose) so the bar communicates pressure at a glance;
|
||||
* empty cells use a dim shade so the empty portion is visible
|
||||
* without dominating. ▰/▱ have distinct shapes (filled vs hollow)
|
||||
* so the boundary is clear even when colors snap to 8-color slots
|
||||
* on legacy terminals.
|
||||
*/
|
||||
function Bar({ ratio, color }: { ratio: number; color: "green" | "yellow" | "red" }) {
|
||||
const cells = 10;
|
||||
function Bar({
|
||||
ratio,
|
||||
color,
|
||||
cells = 14,
|
||||
}: {
|
||||
ratio: number;
|
||||
color: string;
|
||||
cells?: number;
|
||||
}) {
|
||||
const filled = Math.max(0, Math.min(cells, Math.round(ratio * cells)));
|
||||
const bar = "█".repeat(filled) + "░".repeat(cells - filled);
|
||||
return <Text color={color}>{bar}</Text>;
|
||||
return (
|
||||
<Text>
|
||||
<Text color={color}>{"▰".repeat(filled)}</Text>
|
||||
<Text dimColor>{"▱".repeat(cells - filled)}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Welcome card on the empty session. The first thing a user sees
|
||||
* after launching `reasonix code` — needs to communicate, in the
|
||||
* ~5 seconds before they type, three things: brand, what to type,
|
||||
* how to escape. Card framing uses a gradient top rule + left-side
|
||||
* accent bar + sectioned hints so it reads as a designed surface
|
||||
* rather than a bare list. No bordered Boxes — those amplified
|
||||
* Ink's Windows eraseLines miscount.
|
||||
*/
|
||||
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope
|
||||
import React from "react";
|
||||
import { COLOR, gradientCells } from "./theme.js";
|
||||
|
||||
export interface WelcomeBannerProps {
|
||||
/** True when running `reasonix code`. Surfaces code-mode hints. */
|
||||
inCodeMode?: boolean;
|
||||
}
|
||||
|
||||
export function WelcomeBanner({ inCodeMode }: WelcomeBannerProps): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const ruleWidth = Math.min(60, Math.max(28, cols - 4));
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} marginY={1}>
|
||||
<GradientRule width={ruleWidth} />
|
||||
<BarRow>
|
||||
<Text bold color={COLOR.brand}>
|
||||
◈ welcome
|
||||
</Text>
|
||||
<Text dimColor>{" · type a message to start"}</Text>
|
||||
</BarRow>
|
||||
<BarRow />
|
||||
<BarRow>
|
||||
<Text bold color={COLOR.primary}>
|
||||
quick start
|
||||
</Text>
|
||||
</BarRow>
|
||||
<Hint cmd="/help" desc="every command + keyboard shortcut" />
|
||||
<Hint cmd="/skill" desc="invoke a stored playbook" />
|
||||
{inCodeMode ? (
|
||||
<>
|
||||
<Hint cmd="@path" desc="inline a file in your message" />
|
||||
<Hint cmd="!cmd" desc="run a shell command, output goes to context" />
|
||||
</>
|
||||
) : null}
|
||||
<Hint cmd="/exit" desc="quit (Ctrl+C also works)" />
|
||||
<BarRow />
|
||||
<BarRow>
|
||||
<Text dimColor italic>
|
||||
tip:
|
||||
</Text>
|
||||
<Text dimColor>{" Ctrl+J inserts a newline · trailing \\ also continues"}</Text>
|
||||
</BarRow>
|
||||
<Box marginTop={1}>
|
||||
<GradientRule width={ruleWidth} thin />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line gradient rule. `thin` swaps the half-block top/bottom so
|
||||
* the top of the card reads as a "header band" (▄ — bottom-half
|
||||
* filled) and the bottom as "section close" (▁ — minimal pixel
|
||||
* row). Same primitive the StatsPanel uses, kept consistent.
|
||||
*/
|
||||
function GradientRule({ width, thin }: { width: number; thin?: boolean }) {
|
||||
const cells = gradientCells(width, thin ? "▁" : "▄");
|
||||
return (
|
||||
<Box>
|
||||
{cells.map((c, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-width gradient row, never reordered
|
||||
<Text key={`wrule-${i}`} color={c.color}>
|
||||
{c.ch}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card row — left-side accent bar followed by content. Children
|
||||
* optional so we can use this for blank spacer rows (bar only).
|
||||
*/
|
||||
function BarRow({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={COLOR.brand} bold>
|
||||
▎
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single hint row — bold accent cmd token + dim description.
|
||||
* Padded so all cmd tokens line up regardless of length, like a
|
||||
* man-page synopsis.
|
||||
*/
|
||||
function Hint({ cmd, desc }: { cmd: string; desc: string }) {
|
||||
return (
|
||||
<BarRow>
|
||||
<Text bold color={COLOR.accent}>
|
||||
{cmd.padEnd(8)}
|
||||
</Text>
|
||||
<Text dimColor>{` ${desc}`}</Text>
|
||||
</BarRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* CSI sequence recovery for Ink's `useInput`.
|
||||
*
|
||||
* Why this exists. Ink delivers keyboard events through `parse-keypress`,
|
||||
* which reads stdin and recognises ANSI control-sequence-introducer
|
||||
* (CSI) sequences with a state machine plus a short timeout. On most
|
||||
* terminals this works: the byte stream `\x1b [ A` arrives in one
|
||||
* read, parse-keypress emits `{key.upArrow:true, input:""}`, life is
|
||||
* good.
|
||||
*
|
||||
* On Windows PowerShell + ConPTY (and a handful of embedded shells)
|
||||
* the bytes don't always arrive together. parse-keypress's timeout
|
||||
* expires between chunks, so it dispatches the lone `\x1b` as
|
||||
* `key.escape:true` and then dispatches the trailing `[A` as a
|
||||
* regular character event — `input:"[A", key.upArrow:undefined`. The
|
||||
* useInput consumer sees `[A` as plain text, inserts it into the
|
||||
* buffer, and the cursor never moves. Same shape on `[C` (right
|
||||
* arrow), `[Z` (Shift+Tab), `[5~` (PgUp), `[201~` (bracketed-paste
|
||||
* end), etc.
|
||||
*
|
||||
* Every Reasonix UI component that calls `useInput` is exposed to
|
||||
* this. Patching each one to re-recognise the bare CSI tail was
|
||||
* fragile — every release a new symptom appeared (cursor stuck,
|
||||
* paste markers leaking, Shift+Tab not toggling). So this module is
|
||||
* the single source of truth for CSI recovery: every input handler
|
||||
* runs the raw event through `recoverCsiTail` before reading
|
||||
* structured key flags.
|
||||
*
|
||||
* Recovery is conservative. We only rewrite when:
|
||||
* 1. None of the structured key flags Ink already populated for
|
||||
* this event is set (so a real upArrow press isn't second-
|
||||
* guessed).
|
||||
* 2. `input` exactly matches a recognised tail in either form —
|
||||
* the full `\x1b[X` sequence (in case the timeout merged it)
|
||||
* or the bare `[X` ConPTY-stripped variant.
|
||||
*
|
||||
* Anything else passes through unchanged.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Subset of Ink's `Key` interface that contains the structured flags
|
||||
* we know how to recover. Optional everywhere because callers from
|
||||
* different Ink versions may not populate every field.
|
||||
*/
|
||||
export interface CsiKeyFlags {
|
||||
upArrow?: boolean;
|
||||
downArrow?: boolean;
|
||||
leftArrow?: boolean;
|
||||
rightArrow?: boolean;
|
||||
pageUp?: boolean;
|
||||
pageDown?: boolean;
|
||||
delete?: boolean;
|
||||
shift?: boolean;
|
||||
tab?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSI tails we recognise. Each maps the printable bytes of a CSI
|
||||
* tail (the part after the `\x1b`) to the structured key flags they
|
||||
* should set. Bracketed-paste markers `[200~` / `[201~` are
|
||||
* intentionally absent — they're handled by PromptInput's paste
|
||||
* accumulator, which has different semantics (collect content
|
||||
* between markers rather than emit one keystroke).
|
||||
*/
|
||||
const CSI_TAIL_TO_FLAGS: ReadonlyArray<{ tail: string; flags: CsiKeyFlags }> = [
|
||||
// Arrow keys — the most common ConPTY victim.
|
||||
{ tail: "[A", flags: { upArrow: true } },
|
||||
{ tail: "[B", flags: { downArrow: true } },
|
||||
{ tail: "[C", flags: { rightArrow: true } },
|
||||
{ tail: "[D", flags: { leftArrow: true } },
|
||||
// Page navigation.
|
||||
{ tail: "[5~", flags: { pageUp: true } },
|
||||
{ tail: "[6~", flags: { pageDown: true } },
|
||||
// Forward-delete (the key labelled Delete on most keyboards).
|
||||
{ tail: "[3~", flags: { delete: true } },
|
||||
// Shift+Tab — terminal sends `\x1b[Z` rather than tab-with-shift.
|
||||
{ tail: "[Z", flags: { shift: true, tab: true } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Did Ink already give us a structured key for this event? When
|
||||
* `true`, recovery is unnecessary and would risk overriding a real
|
||||
* keypress that happens to share `input` with a CSI tail.
|
||||
*/
|
||||
function alreadyStructured(flags: CsiKeyFlags): boolean {
|
||||
return Boolean(
|
||||
flags.upArrow ||
|
||||
flags.downArrow ||
|
||||
flags.leftArrow ||
|
||||
flags.rightArrow ||
|
||||
flags.pageUp ||
|
||||
flags.pageDown ||
|
||||
flags.delete ||
|
||||
(flags.tab && flags.shift),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If `input` is a recognisable CSI tail (with or without the leading
|
||||
* `\x1b`), return the structured flags it should produce. Otherwise
|
||||
* `null`. Bracketed-paste markers always return `null` here — those
|
||||
* are handled by their own accumulator.
|
||||
*
|
||||
* Pure function. Pass already-structured events through (they
|
||||
* short-circuit) so a real arrow press doesn't get rewritten.
|
||||
*/
|
||||
export function recoverCsiTail(input: string, existing: CsiKeyFlags = {}): CsiKeyFlags | null {
|
||||
if (alreadyStructured(existing)) return null;
|
||||
for (const entry of CSI_TAIL_TO_FLAGS) {
|
||||
if (input === entry.tail || input === `\x1b${entry.tail}`) {
|
||||
return entry.flags;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail forms (with and without the leading `\x1b`) that should never
|
||||
* end up as literal text in a user's prompt buffer. Used as a final
|
||||
* defensive pass before treating input as printable — if a CSI
|
||||
* fragment slipped past structured-key recovery (e.g. arrived
|
||||
* embedded inside a paste burst), at least drop the raw bytes
|
||||
* rather than insert them as garbage text.
|
||||
*
|
||||
* Includes the bracketed-paste markers `[200~` / `[201~` because a
|
||||
* paste whose start/end markers got chunked across reads can leave
|
||||
* those bytes in the printable input.
|
||||
*/
|
||||
export const STRIPPABLE_CSI_FRAGMENTS: readonly string[] = [
|
||||
"\u001b[200~",
|
||||
"\u001b[201~",
|
||||
"[200~",
|
||||
"[201~",
|
||||
...CSI_TAIL_TO_FLAGS.flatMap((e) => [`\u001b${e.tail}`, e.tail]),
|
||||
];
|
||||
|
||||
/** Remove every recognised CSI fragment from a string. */
|
||||
export function stripCsiFragments(input: string): string {
|
||||
let out = input;
|
||||
for (const frag of STRIPPABLE_CSI_FRAGMENTS) {
|
||||
if (out.includes(frag)) out = out.replaceAll(frag, "");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* KeystrokeContext — React surface in front of the raw stdin reader.
|
||||
*
|
||||
* Replaces Ink's `useInput` chain. Reasonix's components no longer
|
||||
* import `useInput` from "ink"; they call `useKeystroke(handler,
|
||||
* isActive)` from this module. The provider mounted once at App
|
||||
* level owns a `StdinReader`, subscribes a single fan-out function
|
||||
* to it, and dispatches each parsed `KeyEvent` to every active
|
||||
* consumer.
|
||||
*
|
||||
* Why a Context instead of a singleton import: the provider can be
|
||||
* disabled in tests / replay mode without touching the components,
|
||||
* and the lifecycle (start/stop on mount/unmount) is tied to the
|
||||
* React tree rather than a global side effect.
|
||||
*
|
||||
* Why not just keep Ink's useInput: Ink's parse-keypress uses a
|
||||
* 100 ms intra-CSI timeout that's too short for Windows ConPTY,
|
||||
* leaking arrow-key bytes / paste markers into the buffer. Our
|
||||
* reader uses 250 ms and recognises the ESC-stripped variants too
|
||||
* — see `stdin-reader.ts`.
|
||||
*/
|
||||
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value
|
||||
import React, { createContext, useContext, useEffect, useRef } from "react";
|
||||
import { type KeyEvent, type StdinReader, getStdinReader } from "./stdin-reader.js";
|
||||
|
||||
interface KeystrokeBus {
|
||||
/** Subscribe — returns an unsubscribe function. */
|
||||
subscribe(handler: KeystrokeHandler): () => void;
|
||||
}
|
||||
|
||||
export type KeystrokeHandler = (ev: KeyEvent) => void;
|
||||
|
||||
const KeystrokeContext = createContext<KeystrokeBus | null>(null);
|
||||
|
||||
export interface KeystrokeProviderProps {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Optional reader override. Tests inject a synthetic reader so
|
||||
* they can `feed()` chunks instead of touching real stdin. Production
|
||||
* callers leave this unset and get the singleton.
|
||||
*/
|
||||
reader?: StdinReader;
|
||||
}
|
||||
|
||||
export function KeystrokeProvider({
|
||||
children,
|
||||
reader: providedReader,
|
||||
}: KeystrokeProviderProps): React.ReactElement {
|
||||
const handlersRef = useRef<Set<KeystrokeHandler>>(new Set());
|
||||
// Ref so the bus value's identity is stable across re-renders —
|
||||
// consumers don't accidentally re-subscribe every render.
|
||||
const busRef = useRef<KeystrokeBus | null>(null);
|
||||
if (busRef.current === null) {
|
||||
busRef.current = {
|
||||
subscribe(handler) {
|
||||
handlersRef.current.add(handler);
|
||||
return () => {
|
||||
handlersRef.current.delete(handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const reader = providedReader ?? getStdinReader();
|
||||
reader.start();
|
||||
const unsubscribe = reader.subscribe((ev) => {
|
||||
// Snapshot the handler set so handlers added/removed during
|
||||
// dispatch don't perturb iteration. Cheap — typical N=1-3.
|
||||
for (const fn of [...handlersRef.current]) fn(ev);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
// Don't `stop()` the singleton on every unmount — multiple
|
||||
// mounts (test reruns, hot-reload) must not tear down stdin.
|
||||
// The singleton's own start() is idempotent; stop() is the
|
||||
// process-exit handler's job.
|
||||
};
|
||||
}, [providedReader]);
|
||||
|
||||
return <KeystrokeContext.Provider value={busRef.current}>{children}</KeystrokeContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to keystroke events. Mirrors Ink's `useInput` shape —
|
||||
* `handler(ev)` runs on every event while `isActive` is truthy
|
||||
* (default true). Set `isActive=false` to suspend the handler
|
||||
* (e.g. while a modal is up and shouldn't respond to global keys).
|
||||
*
|
||||
* Handler identity changes are tolerated — we re-subscribe via
|
||||
* useEffect on every render. Wrap your handler in `useCallback` if
|
||||
* you want to avoid that.
|
||||
*/
|
||||
export function useKeystroke(handler: KeystrokeHandler, isActive = true): void {
|
||||
const bus = useContext(KeystrokeContext);
|
||||
// Latest-handler ref so we can subscribe ONCE per active toggle
|
||||
// and still call the freshest closure on each event.
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
if (!bus || !isActive) return undefined;
|
||||
return bus.subscribe((ev) => handlerRef.current(ev));
|
||||
}, [bus, isActive]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-level hook for components that need a stable subscription
|
||||
* across the lifetime of the consumer (typically StdinReader-aware
|
||||
* unit tests).
|
||||
*/
|
||||
export function useKeystrokeBus(): KeystrokeBus | null {
|
||||
return useContext(KeystrokeContext);
|
||||
}
|
||||
|
||||
/** Test helper — assemble a KeyEvent with sensible defaults. */
|
||||
export function makeKeyEvent(overrides: Partial<KeyEvent> = {}): KeyEvent {
|
||||
return { input: "", ...overrides };
|
||||
}
|
||||
+86
-12
@@ -1053,11 +1053,7 @@ function parseBulletItem(raw: string): BulletItem {
|
||||
function BlockView({ block, citations }: { block: Block; citations?: CitationMap }) {
|
||||
switch (block.kind) {
|
||||
case "heading":
|
||||
return (
|
||||
<Text bold color="cyan">
|
||||
<InlineMd text={block.text} citations={citations} />
|
||||
</Text>
|
||||
);
|
||||
return <HeadingView level={block.level} text={block.text} citations={citations} />;
|
||||
case "paragraph":
|
||||
return <ParagraphView text={block.text} citations={citations} />;
|
||||
case "bullet":
|
||||
@@ -1085,11 +1081,7 @@ function BlockView({ block, citations }: { block: Block; citations?: CitationMap
|
||||
if (DIAGRAM_LANGS.has(block.lang.toLowerCase())) {
|
||||
return <DiagramCodeBlock lang={block.lang} text={block.text} />;
|
||||
}
|
||||
return (
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text color="yellow">{block.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
return <CodeBlockView lang={block.lang} text={block.text} />;
|
||||
case "edit-block":
|
||||
return <EditBlockRow block={block} />;
|
||||
case "table":
|
||||
@@ -1160,8 +1152,7 @@ function BlockquoteView({
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor="gray"
|
||||
borderDimColor
|
||||
borderColor="#c4b5fd"
|
||||
borderTop={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
@@ -1357,6 +1348,89 @@ const DIAGRAM_VIEWER_HINT: Record<string, string> = {
|
||||
* user can tell at a glance that the terminal couldn't draw the
|
||||
* actual graph — they're looking at source to copy out.
|
||||
*/
|
||||
/**
|
||||
* Heading renderer. Three tiers of solid-bg pill (cyan / violet /
|
||||
* fuchsia, walking down the brand gradient) so the heading tree is
|
||||
* scannable at a glance. H4+ collapses to bold accent text with a
|
||||
* leading ▸ marker — four pills stacked would visually overwhelm
|
||||
* the body, and headings rarely go that deep in answers anyway.
|
||||
*
|
||||
* Markdown sigils (#, ##, ###) are NEVER rendered literally inside
|
||||
* the pill: showing `### 标题` defeats the whole "this is a heading"
|
||||
* semantic. The sigil count goes into the pill's BG color choice;
|
||||
* the title text stands on its own.
|
||||
*/
|
||||
function HeadingView({
|
||||
level,
|
||||
text,
|
||||
citations,
|
||||
}: {
|
||||
level: number;
|
||||
text: string;
|
||||
citations?: CitationMap;
|
||||
}) {
|
||||
if (level === 1) {
|
||||
return (
|
||||
<Box marginY={1}>
|
||||
<Text backgroundColor="#67e8f9" color="black" bold>
|
||||
{` ${text} `}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (level === 2) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text backgroundColor="#c4b5fd" color="black" bold>
|
||||
{` ${text} `}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (level === 3) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text backgroundColor="#f0abfc" color="black" bold>
|
||||
{` ${text} `}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text bold color="#f0abfc">
|
||||
▸{" "}
|
||||
</Text>
|
||||
<Text bold>
|
||||
<InlineMd text={text} citations={citations} />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fenced code block. Round-cornered frame, language pill in the
|
||||
* top-left, body in syntax-flavored colors. Lives inside event rows
|
||||
* which Ink renders into `<Static>` (scrollback) — the eraseLines
|
||||
* miscount that bans bordered Boxes from the *live* region doesn't
|
||||
* apply here, since Static items render once and never repaint.
|
||||
*/
|
||||
function CodeBlockView({ lang, text }: { lang: string; text: string }) {
|
||||
const langLabel = lang.trim();
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="#7dd3fc" paddingX={1}>
|
||||
{langLabel ? (
|
||||
<Box>
|
||||
<Text backgroundColor="#7dd3fc" color="black" bold>
|
||||
{` ${langLabel} `}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Text color="#fde68a">{text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramCodeBlock({ lang, text }: { lang: string; text: string }) {
|
||||
const hint =
|
||||
DIAGRAM_VIEWER_HINT[lang.toLowerCase()] ?? "→ render with the matching viewer to view";
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
* - Parent owns Tab, Esc, PageUp/Down (slash-complete, abort,
|
||||
* unused). Arrow keys are split: empty buffer → parent (history
|
||||
* recall); non-empty → child (cursor movement).
|
||||
*
|
||||
* CSI recovery is delegated to `key-normalize.ts` — see there for
|
||||
* the rationale. Every event flows through `recoverCsiTail` first,
|
||||
* which is the single source of truth for the Windows ConPTY case
|
||||
* (parse-keypress eats the leading `\x1b` and routes the bare
|
||||
* `[A`/`[C`/`[201~`/etc. through useInput as plain text).
|
||||
*/
|
||||
|
||||
export interface MultilineKey {
|
||||
@@ -67,31 +73,22 @@ export interface MultilineAction {
|
||||
pasteRequest?: { content: string };
|
||||
}
|
||||
|
||||
import { recoverCsiTail, stripCsiFragments } from "./key-normalize.js";
|
||||
|
||||
const BACKSLASH_SUFFIX = /\\$/;
|
||||
|
||||
const NOOP: MultilineAction = { next: null, cursor: null, submit: false };
|
||||
|
||||
function rewriteRawArrowEscape(key: MultilineKey): MultilineKey {
|
||||
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return key;
|
||||
if (key.input === "\x1b[A") return { ...key, upArrow: true, input: "" };
|
||||
if (key.input === "\x1b[B") return { ...key, downArrow: true, input: "" };
|
||||
if (key.input === "\x1b[C") return { ...key, rightArrow: true, input: "" };
|
||||
if (key.input === "\x1b[D") return { ...key, leftArrow: true, input: "" };
|
||||
return key;
|
||||
}
|
||||
|
||||
export function processMultilineKey(
|
||||
value: string,
|
||||
cursor: number,
|
||||
keyIn: MultilineKey,
|
||||
): MultilineAction {
|
||||
// Raw-escape fallback for terminals (some Windows configurations —
|
||||
// cmd.exe, older winpty) that don't set `key.upArrow` / etc. on
|
||||
// arrow-key press and instead leak the raw ANSI CSI sequence into
|
||||
// `input`. Without this, `\x1b[A` lands on the "printable" branch
|
||||
// below and the user sees literal escape bytes inserted into their
|
||||
// buffer. Rewrite the event into the structured key here.
|
||||
const key: MultilineKey = rewriteRawArrowEscape(keyIn);
|
||||
// CSI recovery — bare `[A` / `[C` / `[Z` / `[5~` / etc. that
|
||||
// Windows ConPTY leaves in `input` after parse-keypress eats the
|
||||
// leading `\x1b`. See key-normalize.ts for the long version.
|
||||
const recovered = recoverCsiTail(keyIn.input, keyIn);
|
||||
const key: MultilineKey = recovered ? { ...keyIn, ...recovered, input: "" } : keyIn;
|
||||
|
||||
// Parent-owned keys: Tab (slash-complete), Esc (abort).
|
||||
if (key.tab || key.escape) {
|
||||
@@ -176,7 +173,10 @@ export function processMultilineKey(
|
||||
// the content. Pastes always insert; Enter only submits typed
|
||||
// content. We normalize \r\n and bare \r to \n so mixed-line-
|
||||
// ending pastes (Windows clipboard, web copy) land cleanly.
|
||||
const stripped = key.input.replaceAll("\u001b[200~", "").replaceAll("\u001b[201~", "");
|
||||
// Strip every recognised CSI fragment (paste markers, arrow tails,
|
||||
// etc.) defensively — if any leaked past structured-key recovery
|
||||
// they shouldn't get inserted into the buffer as text.
|
||||
const stripped = stripCsiFragments(key.input);
|
||||
// Paste = newline-containing input with MORE than just the newline
|
||||
// itself. A bare "\n" is Ctrl+J / one-keystroke newline (handled
|
||||
// below); only multi-char input wrapped around a newline is a real
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Pure helpers for rendering one logical line of the prompt input as
|
||||
* exactly one visual row, regardless of how wide the line's content
|
||||
* is. Splitting this out from PromptInput so the math is testable
|
||||
* without React/Ink.
|
||||
*
|
||||
* Why we can't trust Ink/Yoga's text wrap: Ink uses `string-width` to
|
||||
* measure cells, but on CJK Windows terminals some glyphs render at
|
||||
* a different cell count than `string-width` reports. The terminal
|
||||
* wraps to a 2nd visual row, Ink's eraseLines miscounts, and the
|
||||
* scrollback fills with ghost rows from the previous frame's leak.
|
||||
*
|
||||
* Our fix is to never let the line wrap. We slice the text down to
|
||||
* the visible-cell budget ourselves and feed Ink a single Text whose
|
||||
* width is known to fit.
|
||||
*
|
||||
* Cursor visibility: when the user types past the visible width the
|
||||
* window slides so the cursor stays in view. `‹` and `›` markers
|
||||
* appear at the trimmed edges so the user can see content is hidden
|
||||
* to one side.
|
||||
*/
|
||||
|
||||
import { type PasteEntry, decodePasteSentinel, formatBytesShort } from "./paste-sentinels.js";
|
||||
|
||||
/**
|
||||
* One renderable atom of a viewport. The line's content is broken
|
||||
* into a sequence of these so a paste sentinel becomes a magenta
|
||||
* `[paste #N · Ml · KKB]` block while the surrounding text stays
|
||||
* normal.
|
||||
*/
|
||||
export type Segment = { kind: "text"; text: string } | { kind: "paste"; id: number; label: string };
|
||||
|
||||
export interface Viewport {
|
||||
/** Segments to render left-to-right. Sum of cells <= visibleCells. */
|
||||
segments: Segment[];
|
||||
/**
|
||||
* Visual cell column at which to draw the cursor block, measured
|
||||
* from the start of the viewport (0 = leftmost cell after the
|
||||
* prompt prefix). `null` means cursor is not on this line — the
|
||||
* caller renders a static line.
|
||||
*/
|
||||
cursorCell: number | null;
|
||||
/** True when content was clipped on the left side. */
|
||||
hiddenLeft: boolean;
|
||||
/** True when content was clipped on the right side. */
|
||||
hiddenRight: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell width of a single character in a typical terminal grid.
|
||||
*
|
||||
* Approximation of Unicode East Asian Width=Wide / W treatment.
|
||||
* Range table is intentionally compact — covers the codepoints we
|
||||
* see in practice (CJK ideographs, fullwidth forms, Hangul syllables,
|
||||
* Hiragana / Katakana). Anything outside the wide ranges counts as
|
||||
* one cell. Control characters count as zero so we don't over-budget
|
||||
* for them.
|
||||
*
|
||||
* Not a full `string-width` clone. We deliberately don't honour the
|
||||
* "Ambiguous=2 in CJK locale" rule because it produces inconsistent
|
||||
* results across terminals and is the very source of Ink's wrap
|
||||
* miscount. Treating ambiguous chars as 1 cell here matches what
|
||||
* Ink/Yoga also does, so our slice math agrees with what Ink would
|
||||
* have rendered (had it not wrapped).
|
||||
*/
|
||||
export function charCells(ch: string): number {
|
||||
if (ch.length === 0) return 0;
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code < 0x20 || code === 0x7f) return 0;
|
||||
if (code < 0x1100) return 1;
|
||||
// Hangul Jamo
|
||||
if (code >= 0x1100 && code <= 0x115f) return 2;
|
||||
// CJK Radicals, Kangxi Radicals, Ideographic Description, CJK Symbols
|
||||
if (code >= 0x2e80 && code <= 0x303e) return 2;
|
||||
// Hiragana, Katakana, Bopomofo, Hangul Compat Jamo, Kanbun
|
||||
if (code >= 0x3041 && code <= 0x33ff) return 2;
|
||||
// CJK Unified Ext A
|
||||
if (code >= 0x3400 && code <= 0x4dbf) return 2;
|
||||
// CJK Unified Ideographs
|
||||
if (code >= 0x4e00 && code <= 0x9fff) return 2;
|
||||
// Yi Syllables
|
||||
if (code >= 0xa000 && code <= 0xa4cf) return 2;
|
||||
// Hangul Syllables
|
||||
if (code >= 0xac00 && code <= 0xd7a3) return 2;
|
||||
// CJK Compatibility Ideographs
|
||||
if (code >= 0xf900 && code <= 0xfaff) return 2;
|
||||
// CJK Compatibility Forms
|
||||
if (code >= 0xfe30 && code <= 0xfe4f) return 2;
|
||||
// Halfwidth and Fullwidth Forms (fullwidth half is wide)
|
||||
if (code >= 0xff00 && code <= 0xff60) return 2;
|
||||
// Fullwidth signs
|
||||
if (code >= 0xffe0 && code <= 0xffe6) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Total cells of a string, with paste sentinels expanded to placeholder width. */
|
||||
export function stringCells(s: string, pastes?: ReadonlyMap<number, PasteEntry>): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i]!;
|
||||
const id = decodePasteSentinel(ch);
|
||||
if (id !== null) {
|
||||
n += pasteSentinelCells(id, pastes);
|
||||
} else {
|
||||
n += charCells(ch);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Paste sentinel placeholder width — what we'd render for that sentinel. */
|
||||
export function pasteSentinelLabel(id: number, entry: PasteEntry | undefined): string {
|
||||
if (!entry) return `[paste #${id + 1} · (missing)]`;
|
||||
return `[paste #${id + 1} · ${entry.lineCount}l · ${formatBytesShort(entry.charCount)}]`;
|
||||
}
|
||||
|
||||
function pasteSentinelCells(id: number, pastes?: ReadonlyMap<number, PasteEntry>): number {
|
||||
const entry = pastes?.get(id);
|
||||
return pasteSentinelLabel(id, entry).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the viewport for a line, keeping the cursor visible. When
|
||||
* the line's total cells fit in `visibleCells`, return the whole
|
||||
* line. Otherwise slice a window centred on the cursor with `‹` /
|
||||
* `›` markers indicating clipped sides.
|
||||
*
|
||||
* `cursorCol` is the cursor's char index within `line` (0 = before
|
||||
* first char; `line.length` = past last char). Pass `null` for
|
||||
* lines that don't host the cursor — the function returns a left-
|
||||
* aligned static viewport with truncation on the right.
|
||||
*/
|
||||
export function buildViewport(
|
||||
line: string,
|
||||
cursorCol: number | null,
|
||||
visibleCells: number,
|
||||
pastes?: ReadonlyMap<number, PasteEntry>,
|
||||
): Viewport {
|
||||
if (visibleCells <= 0) {
|
||||
return {
|
||||
segments: [],
|
||||
cursorCell: cursorCol === null ? null : 0,
|
||||
hiddenLeft: false,
|
||||
hiddenRight: line.length > 0,
|
||||
};
|
||||
}
|
||||
const totalCells = stringCells(line, pastes);
|
||||
|
||||
// Fast path: whole line fits.
|
||||
if (totalCells <= visibleCells) {
|
||||
const segments = textToSegments(line, pastes);
|
||||
let cursorCell: number | null = null;
|
||||
if (cursorCol !== null) {
|
||||
cursorCell = stringCells(line.slice(0, cursorCol), pastes);
|
||||
}
|
||||
return { segments, cursorCell, hiddenLeft: false, hiddenRight: false };
|
||||
}
|
||||
|
||||
// Static viewport (cursor not on this line) — clip from the right.
|
||||
if (cursorCol === null) {
|
||||
return clipFromLeft(line, visibleCells, pastes);
|
||||
}
|
||||
|
||||
// Cursor-bearing line: slide a window so cursor stays visible.
|
||||
// Reserve 1 cell on each potentially-clipped side for the marker.
|
||||
return clipAroundCursor(line, cursorCol, visibleCells, pastes);
|
||||
}
|
||||
|
||||
function clipFromLeft(
|
||||
line: string,
|
||||
visibleCells: number,
|
||||
pastes?: ReadonlyMap<number, PasteEntry>,
|
||||
): Viewport {
|
||||
// Show as much of the head as fits; mark the right edge as hidden.
|
||||
// Reserve 1 cell for the `›` marker.
|
||||
const budget = Math.max(1, visibleCells - 1);
|
||||
let used = 0;
|
||||
let end = 0;
|
||||
while (end < line.length) {
|
||||
const ch = line[end]!;
|
||||
const cw = charCellsAt(line, end, pastes);
|
||||
if (used + cw > budget) break;
|
||||
used += cw;
|
||||
end++;
|
||||
}
|
||||
const segments = textToSegments(line.slice(0, end), pastes);
|
||||
return { segments, cursorCell: null, hiddenLeft: false, hiddenRight: end < line.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding window around the cursor. Algorithm:
|
||||
*
|
||||
* 1. Start with `start` and `end` both at the cursor index.
|
||||
* 2. Expand `end` rightward while there's budget, until cursor cell
|
||||
* visible or we hit end-of-line.
|
||||
* 3. Expand `start` leftward to fill remaining budget.
|
||||
* 4. Reserve 1 cell on each clipped side for the `‹`/`›` marker.
|
||||
*
|
||||
* The cursor cell is computed AFTER the slice is fixed, by counting
|
||||
* cells from `start` up to `cursorCol`.
|
||||
*/
|
||||
function clipAroundCursor(
|
||||
line: string,
|
||||
cursorCol: number,
|
||||
visibleCells: number,
|
||||
pastes?: ReadonlyMap<number, PasteEntry>,
|
||||
): Viewport {
|
||||
// `cursorCol` is between 0 and line.length (inclusive). The cursor
|
||||
// visually sits BEFORE the char at line[cursorCol] (or after the
|
||||
// last char when cursorCol === line.length).
|
||||
// We want both the char at the cursor (if any) AND a cell of cursor
|
||||
// padding visible.
|
||||
|
||||
// Budget — leave 1 cell for each marker we may need.
|
||||
let budget = visibleCells;
|
||||
// Right marker: needed if we don't reach end of line.
|
||||
// Left marker: needed if start > 0.
|
||||
// We don't know in advance, so allocate conservatively: -2 cells.
|
||||
const reservedForMarkers = 2;
|
||||
budget = Math.max(1, budget - reservedForMarkers);
|
||||
|
||||
// Try to keep cursor roughly centred. Start by aiming `start` ~
|
||||
// halfway behind cursorCol.
|
||||
const halfBudget = Math.floor(budget / 2);
|
||||
|
||||
// Walk left from cursor, accumulating cells, until we've spent
|
||||
// halfBudget OR hit the start of the line.
|
||||
let start = cursorCol;
|
||||
let leftCells = 0;
|
||||
while (start > 0 && leftCells < halfBudget) {
|
||||
const cw = charCellsAt(line, start - 1, pastes);
|
||||
if (leftCells + cw > halfBudget) break;
|
||||
start--;
|
||||
leftCells += cw;
|
||||
}
|
||||
|
||||
// Walk right from cursor, filling the remaining budget. We always
|
||||
// include a cell for the cursor itself if line[cursorCol] exists
|
||||
// (since the cursor block covers that char). At end-of-line we
|
||||
// include a phantom cell of cursor space.
|
||||
const rightBudget = budget - leftCells;
|
||||
let end = cursorCol;
|
||||
let rightCells = 0;
|
||||
// Include the char at the cursor (1 or 2 cells depending on width)
|
||||
// if there is one.
|
||||
const cursorChar = cursorCol < line.length ? charCellsAt(line, cursorCol, pastes) : 1;
|
||||
if (rightBudget >= cursorChar) {
|
||||
if (cursorCol < line.length) end = cursorCol + 1;
|
||||
rightCells = cursorChar;
|
||||
while (end < line.length && rightCells < rightBudget) {
|
||||
const cw = charCellsAt(line, end, pastes);
|
||||
if (rightCells + cw > rightBudget) break;
|
||||
rightCells += cw;
|
||||
end++;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have leftover right-budget and there's still room on the
|
||||
// left, expand leftwards more (cursor stays towards the right
|
||||
// edge but more left context is shown — common when typing at
|
||||
// end of a long line).
|
||||
let extraLeftBudget = rightBudget - rightCells;
|
||||
while (start > 0 && extraLeftBudget > 0) {
|
||||
const cw = charCellsAt(line, start - 1, pastes);
|
||||
if (cw > extraLeftBudget) break;
|
||||
start--;
|
||||
leftCells += cw;
|
||||
extraLeftBudget -= cw;
|
||||
}
|
||||
|
||||
const hiddenLeft = start > 0;
|
||||
const hiddenRight = end < line.length;
|
||||
const segments = textToSegments(line.slice(start, end), pastes);
|
||||
// Cursor cell relative to the start of the slice. Markers are
|
||||
// rendered separately by the caller — they don't shift the
|
||||
// segment-relative offset so we don't add them here.
|
||||
const cursorCell = stringCells(line.slice(start, cursorCol), pastes);
|
||||
return { segments, cursorCell, hiddenLeft, hiddenRight };
|
||||
}
|
||||
|
||||
function charCellsAt(line: string, idx: number, pastes?: ReadonlyMap<number, PasteEntry>): number {
|
||||
const ch = line[idx]!;
|
||||
const id = decodePasteSentinel(ch);
|
||||
if (id !== null) {
|
||||
const entry = pastes?.get(id);
|
||||
return pasteSentinelLabel(id, entry).length;
|
||||
}
|
||||
return charCells(ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the string and split it into text/paste segments. Pure —
|
||||
* doesn't allocate per-character objects (text runs accumulate in a
|
||||
* single segment). Used by both the fast and clipped paths.
|
||||
*/
|
||||
export function textToSegments(line: string, pastes?: ReadonlyMap<number, PasteEntry>): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
let buf = "";
|
||||
const flushBuf = () => {
|
||||
if (buf.length > 0) {
|
||||
out.push({ kind: "text", text: buf });
|
||||
buf = "";
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]!;
|
||||
const id = decodePasteSentinel(ch);
|
||||
if (id !== null) {
|
||||
flushBuf();
|
||||
const label = pasteSentinelLabel(id, pastes?.get(id));
|
||||
out.push({ kind: "paste", id, label });
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
}
|
||||
flushBuf();
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Raw stdin reader with our own CSI parser.
|
||||
*
|
||||
* Replaces Ink's `useInput` chain (which uses `parse-keypress` with a
|
||||
* 100 ms intra-sequence timeout that's too short for Windows ConPTY).
|
||||
* That timeout was the root cause of every keyboard regression we
|
||||
* shipped a patch for in 0.7.x — paste markers leaking into the
|
||||
* buffer, arrow keys not crossing newlines, Shift+Tab silently failing.
|
||||
*
|
||||
* Design:
|
||||
* - One reader owns `process.stdin`. We call `setRawMode(true)`,
|
||||
* register a single `data` listener, and emit parsed `KeyEvent`s
|
||||
* to subscribers. As long as no `useInput` is called inside the
|
||||
* Ink tree, Ink itself never attaches its own listener — we're
|
||||
* the sole consumer of stdin.
|
||||
*
|
||||
* - The state machine is tiny: idle → esc → csi/ss3 → idle, plus a
|
||||
* `paste` accumulator that swallows everything between `\x1b[200~`
|
||||
* and `\x1b[201~`. ESC ambiguity (standalone Esc vs. start of a
|
||||
* CSI) is resolved by a 250 ms timer — much longer than parse-
|
||||
* keypress's 100 ms, so ConPTY-split sequences land in the
|
||||
* correct branch.
|
||||
*
|
||||
* - We also recognise the ESC-stripped variants (`[A`, `[200~`) at
|
||||
* idle-state lookahead, in case ConPTY consumed the leading ESC
|
||||
* before we even saw it. That used to be a recovery layer in
|
||||
* `key-normalize.ts`; with this reader as the sole input source
|
||||
* it folds into the parser proper.
|
||||
*
|
||||
* - `paste` events carry the full content so the consumer can route
|
||||
* it through paste-sentinel registration without re-parsing.
|
||||
*/
|
||||
|
||||
import { stdin } from "node:process";
|
||||
|
||||
/**
|
||||
* Single keystroke event. Shape mirrors Ink's `(input, key)` callback
|
||||
* pair for migration ergonomics — consumers reading `key.upArrow`
|
||||
* etc. can keep doing so. `paste` is the one new field; consumers
|
||||
* that care about pastes (PromptInput) check it explicitly.
|
||||
*/
|
||||
export interface KeyEvent {
|
||||
/**
|
||||
* Printable character(s). Non-empty for normal typing, Ctrl+letter
|
||||
* (the letter goes here with `ctrl:true`), Alt+letter (with
|
||||
* `meta:true`), and bracketed paste content (with `paste:true`).
|
||||
* Always empty for control keystrokes like arrows / Enter / Esc.
|
||||
*/
|
||||
input: string;
|
||||
upArrow?: boolean;
|
||||
downArrow?: boolean;
|
||||
leftArrow?: boolean;
|
||||
rightArrow?: boolean;
|
||||
pageUp?: boolean;
|
||||
pageDown?: boolean;
|
||||
home?: boolean;
|
||||
end?: boolean;
|
||||
delete?: boolean;
|
||||
backspace?: boolean;
|
||||
tab?: boolean;
|
||||
return?: boolean;
|
||||
escape?: boolean;
|
||||
shift?: boolean;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
/**
|
||||
* True iff this event delivers bracketed-paste content. `input`
|
||||
* holds the entire paste; consumers MUST NOT re-interpret it as
|
||||
* keystrokes (e.g. a `\n` in a paste shouldn't fire submit).
|
||||
*/
|
||||
paste?: boolean;
|
||||
}
|
||||
|
||||
type Subscriber = (ev: KeyEvent) => void;
|
||||
|
||||
/** ESC ambiguity timeout. Long enough for ConPTY-split sequences. */
|
||||
const ESC_TIMEOUT_MS = 250;
|
||||
|
||||
/** Bracketed-paste markers (DECSET 2004). */
|
||||
const PASTE_START = "\x1b[200~";
|
||||
const PASTE_END = "\x1b[201~";
|
||||
/** ESC-stripped variants — ConPTY occasionally eats the leading ESC. */
|
||||
const PASTE_START_BARE = "[200~";
|
||||
const PASTE_END_BARE = "[201~";
|
||||
|
||||
/**
|
||||
* CSI tails (the bytes that follow `\x1b[`) we recognise and the
|
||||
* structured event each produces. Order doesn't matter — lookups
|
||||
* are exact-match.
|
||||
*/
|
||||
const CSI_TAIL_MAP: ReadonlyArray<{ tail: string; ev: KeyEvent }> = [
|
||||
{ tail: "A", ev: { input: "", upArrow: true } },
|
||||
{ tail: "B", ev: { input: "", downArrow: true } },
|
||||
{ tail: "C", ev: { input: "", rightArrow: true } },
|
||||
{ tail: "D", ev: { input: "", leftArrow: true } },
|
||||
{ tail: "H", ev: { input: "", home: true } },
|
||||
{ tail: "F", ev: { input: "", end: true } },
|
||||
{ tail: "1~", ev: { input: "", home: true } },
|
||||
{ tail: "4~", ev: { input: "", end: true } },
|
||||
{ tail: "5~", ev: { input: "", pageUp: true } },
|
||||
{ tail: "6~", ev: { input: "", pageDown: true } },
|
||||
{ tail: "3~", ev: { input: "", delete: true } },
|
||||
{ tail: "Z", ev: { input: "", shift: true, tab: true } },
|
||||
// modifyOtherKeys (xterm CSI > 4 ; 2 m) sequences for Enter with
|
||||
// modifiers. Only fired when App.tsx has enabled the mode at
|
||||
// startup; otherwise Shift+Enter stays indistinguishable from Enter.
|
||||
// Modifier encoding: 2=shift, 3=alt, 4=alt+shift, 5=ctrl,
|
||||
// 6=ctrl+shift, 7=ctrl+alt, 8=ctrl+alt+shift. Keycode 13 = Enter.
|
||||
{ tail: "27;2;13~", ev: { input: "", return: true, shift: true } },
|
||||
{ tail: "27;5;13~", ev: { input: "", return: true, ctrl: true } },
|
||||
{ tail: "27;6;13~", ev: { input: "", return: true, ctrl: true, shift: true } },
|
||||
// Kitty keyboard protocol — same idea, different envelope:
|
||||
// `\x1b[<keycode>;<mod>u`. Some terminals (kitty, recent Windows
|
||||
// Terminal previews) prefer this shape. Harmless to map here too.
|
||||
{ tail: "13;2u", ev: { input: "", return: true, shift: true } },
|
||||
{ tail: "13;5u", ev: { input: "", return: true, ctrl: true } },
|
||||
{ tail: "13;6u", ev: { input: "", return: true, ctrl: true, shift: true } },
|
||||
];
|
||||
|
||||
/** SS3 sequences (`\x1bO<letter>`) — some terminals send these for arrows. */
|
||||
const SS3_MAP: Record<string, KeyEvent> = {
|
||||
A: { input: "", upArrow: true },
|
||||
B: { input: "", downArrow: true },
|
||||
C: { input: "", rightArrow: true },
|
||||
D: { input: "", leftArrow: true },
|
||||
H: { input: "", home: true },
|
||||
F: { input: "", end: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Lookahead matcher for ESC-stripped sequences. When `chunk[i]` is
|
||||
* `[`, we check the bytes immediately after to see if they form a
|
||||
* recognised CSI tail. If so we emit the structured event and tell
|
||||
* the caller how many bytes to consume. Returns `null` when no
|
||||
* recognised tail starts here.
|
||||
*/
|
||||
function tryEscapelessCsi(chunk: string, i: number): { advance: number; ev: KeyEvent } | null {
|
||||
if (chunk[i] !== "[") return null;
|
||||
// Paste start as a special case (handled by caller).
|
||||
// Try each known tail.
|
||||
for (const entry of CSI_TAIL_MAP) {
|
||||
const candidate = `[${entry.tail}`;
|
||||
if (chunk.slice(i, i + candidate.length) === candidate) {
|
||||
return { advance: candidate.length, ev: entry.ev };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final byte of a CSI sequence is in the range `0x40` (`@`) –
|
||||
* `0x7E` (`~`). Anything else is a parameter / intermediate byte.
|
||||
*/
|
||||
function isCsiFinal(ch: string): boolean {
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= 0x40 && code <= 0x7e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the CSI event for a fully-assembled sequence (without the
|
||||
* leading `\x1b[`). Returns `null` if we don't recognise it — which
|
||||
* is fine, we just drop the bytes silently rather than insert them
|
||||
* into the buffer as garbage text.
|
||||
*/
|
||||
function lookupCsi(tail: string): KeyEvent | null {
|
||||
for (const entry of CSI_TAIL_MAP) {
|
||||
if (entry.tail === tail) return entry.ev;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class StdinReader {
|
||||
private subscribers = new Set<Subscriber>();
|
||||
private state: "idle" | "esc" | "csi" | "ss3" | "paste" = "idle";
|
||||
/** Buffer for partial sequences across chunks. */
|
||||
private csiBuf = "";
|
||||
/** Buffer for paste content. */
|
||||
private pasteBuf = "";
|
||||
private escTimer: NodeJS.Timeout | null = null;
|
||||
private started = false;
|
||||
/** The actual `data` listener — kept as a field so `stop()` can detach it. */
|
||||
private listener: ((chunk: Buffer | string) => void) | null = null;
|
||||
|
||||
start(): void {
|
||||
if (this.started) return;
|
||||
if (!stdin.isTTY) {
|
||||
// Non-TTY (piped input). We can't run interactively — don't try.
|
||||
return;
|
||||
}
|
||||
stdin.setRawMode(true);
|
||||
stdin.setEncoding("utf8");
|
||||
stdin.resume();
|
||||
this.listener = (chunk) =>
|
||||
this.handleChunk(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
||||
stdin.on("data", this.listener);
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.started) return;
|
||||
if (this.listener) {
|
||||
stdin.off("data", this.listener);
|
||||
this.listener = null;
|
||||
}
|
||||
if (stdin.isTTY) {
|
||||
try {
|
||||
stdin.setRawMode(false);
|
||||
} catch {
|
||||
// setRawMode may throw if stdin is already closed; ignore.
|
||||
}
|
||||
}
|
||||
stdin.pause();
|
||||
this.cancelEscTimer();
|
||||
this.state = "idle";
|
||||
this.csiBuf = "";
|
||||
this.pasteBuf = "";
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to parsed key events. Returns an unsubscribe function.
|
||||
* Multiple subscribers are supported — every event fans out to all
|
||||
* of them; the React Context layer above uses one subscriber and
|
||||
* dispatches further to its own consumer list.
|
||||
*/
|
||||
subscribe(fn: Subscriber): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => {
|
||||
this.subscribers.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a chunk of bytes as if it came from stdin. Used by tests
|
||||
* to drive the parser without a real TTY.
|
||||
*/
|
||||
feed(chunk: string): void {
|
||||
this.handleChunk(chunk);
|
||||
}
|
||||
|
||||
private dispatch(ev: KeyEvent): void {
|
||||
for (const sub of this.subscribers) sub(ev);
|
||||
}
|
||||
|
||||
private cancelEscTimer(): void {
|
||||
if (this.escTimer) {
|
||||
clearTimeout(this.escTimer);
|
||||
this.escTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleEscTimer(): void {
|
||||
this.cancelEscTimer();
|
||||
this.escTimer = setTimeout(() => {
|
||||
// Standalone Esc — no follow-up byte arrived in time.
|
||||
if (this.state === "esc") {
|
||||
this.state = "idle";
|
||||
this.dispatch({ input: "", escape: true });
|
||||
}
|
||||
}, ESC_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private handleChunk(chunk: string): void {
|
||||
this.cancelEscTimer();
|
||||
let i = 0;
|
||||
while (i < chunk.length) {
|
||||
// ── paste accumulator ──
|
||||
if (this.state === "paste") {
|
||||
// Look for end marker (with or without ESC).
|
||||
const endA = chunk.indexOf(PASTE_END, i);
|
||||
const endB = chunk.indexOf(PASTE_END_BARE, i);
|
||||
let endIdx = -1;
|
||||
let endLen = 0;
|
||||
if (endA !== -1 && (endB === -1 || endA <= endB)) {
|
||||
endIdx = endA;
|
||||
endLen = PASTE_END.length;
|
||||
} else if (endB !== -1) {
|
||||
endIdx = endB;
|
||||
endLen = PASTE_END_BARE.length;
|
||||
}
|
||||
if (endIdx === -1) {
|
||||
this.pasteBuf += chunk.slice(i);
|
||||
i = chunk.length;
|
||||
break;
|
||||
}
|
||||
this.pasteBuf += chunk.slice(i, endIdx);
|
||||
this.dispatch({ input: this.pasteBuf, paste: true });
|
||||
this.pasteBuf = "";
|
||||
this.state = "idle";
|
||||
i = endIdx + endLen;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── CSI accumulator ──
|
||||
if (this.state === "csi") {
|
||||
const ch = chunk[i]!;
|
||||
this.csiBuf += ch;
|
||||
if (isCsiFinal(ch)) {
|
||||
this.dispatchCsi(this.csiBuf);
|
||||
this.csiBuf = "";
|
||||
// Only reset state if `dispatchCsi` didn't already mutate it
|
||||
// (it transitions to `paste` for the `200~` start marker —
|
||||
// resetting here would clobber that and the paste content
|
||||
// would be parsed as keystrokes).
|
||||
if (this.state === "csi") this.state = "idle";
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── SS3 single-byte tail ──
|
||||
if (this.state === "ss3") {
|
||||
const ev = SS3_MAP[chunk[i]!];
|
||||
if (ev) this.dispatch(ev);
|
||||
this.state = "idle";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── ESC pending ──
|
||||
if (this.state === "esc") {
|
||||
const ch = chunk[i]!;
|
||||
if (ch === "[") {
|
||||
this.state = "csi";
|
||||
this.csiBuf = "";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "O") {
|
||||
this.state = "ss3";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// ESC + any other char = Alt+key (rare; we still dispatch).
|
||||
this.dispatch({ input: ch, meta: true });
|
||||
this.state = "idle";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── idle ──
|
||||
const ch = chunk[i]!;
|
||||
|
||||
if (ch === "\x1b") {
|
||||
this.state = "esc";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ESC-stripped paste-start (ConPTY): bare `[200~` at idle.
|
||||
if (chunk.slice(i, i + PASTE_START_BARE.length) === PASTE_START_BARE) {
|
||||
this.state = "paste";
|
||||
this.pasteBuf = "";
|
||||
i += PASTE_START_BARE.length;
|
||||
continue;
|
||||
}
|
||||
// ESC-stripped CSI tails — recover before treating `[` as text.
|
||||
const escapeless = tryEscapelessCsi(chunk, i);
|
||||
if (escapeless) {
|
||||
this.dispatch(escapeless.ev);
|
||||
i += escapeless.advance;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single-byte control keys.
|
||||
// \r (CR, 0x0D) is Enter on every terminal in raw mode.
|
||||
// \n (LF, 0x0A) is what Ctrl+J emits — keep it distinct so the
|
||||
// multiline reducer can map it to "insert newline" instead of
|
||||
// "submit". Pastes containing \n still arrive via either the
|
||||
// bracketed-paste accumulator or a multi-byte printable chunk
|
||||
// that includes the newline; neither hits this single-byte
|
||||
// branch, so this split is safe.
|
||||
if (ch === "\r") {
|
||||
this.dispatch({ input: "", return: true });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\n") {
|
||||
this.dispatch({ input: "j", ctrl: true });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\t") {
|
||||
this.dispatch({ input: "", tab: true });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\x7f" || ch === "\b") {
|
||||
this.dispatch({ input: "", backspace: true });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\x03") {
|
||||
// Ctrl+C — terminate the process. Raw mode disables the
|
||||
// default SIGINT, so we have to handle it ourselves.
|
||||
this.dispatch({ input: "c", ctrl: true });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const code = ch.charCodeAt(0);
|
||||
// Other Ctrl+letter (0x01-0x1A → A-Z, except already-handled).
|
||||
if (code >= 1 && code <= 26) {
|
||||
const letter = String.fromCharCode(0x60 + code); // a..z
|
||||
this.dispatch({ input: letter, ctrl: true });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular printable input. Coalesce a run of printable chars
|
||||
// into one event so a multi-byte UTF-8 paste-burst arrives as
|
||||
// one `input` rather than N adjacent events.
|
||||
let end = i + 1;
|
||||
while (end < chunk.length) {
|
||||
const c = chunk[end]!;
|
||||
if (c === "\x1b" || c === "\r" || c === "\n" || c === "\t") break;
|
||||
if (c === "\x7f" || c === "\b" || c === "\x03") break;
|
||||
const cc = c.charCodeAt(0);
|
||||
if (cc >= 1 && cc <= 26) break;
|
||||
// Don't swallow into a printable run if a CSI / paste prefix
|
||||
// starts at this position.
|
||||
if (c === "[" && tryEscapelessCsi(chunk, end)) break;
|
||||
if (chunk.slice(end, end + PASTE_START_BARE.length) === PASTE_START_BARE) break;
|
||||
end++;
|
||||
}
|
||||
this.dispatch({ input: chunk.slice(i, end) });
|
||||
i = end;
|
||||
}
|
||||
|
||||
// After processing, if we're still in `esc` state, schedule the
|
||||
// ambiguity timer. The next chunk may carry the rest of the CSI;
|
||||
// if not, the timer fires and dispatches a standalone Esc.
|
||||
if (this.state === "esc") {
|
||||
this.scheduleEscTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchCsi(seq: string): void {
|
||||
// seq is the bytes after `\x1b[`, e.g. "A", "5~", "200~", "Z".
|
||||
if (seq === "200~") {
|
||||
this.state = "paste";
|
||||
this.pasteBuf = "";
|
||||
return;
|
||||
}
|
||||
if (seq === "201~") {
|
||||
// Stray paste-end — we shouldn't reach here outside paste mode,
|
||||
// but if we do, drop it silently.
|
||||
return;
|
||||
}
|
||||
const ev = lookupCsi(seq);
|
||||
if (ev) this.dispatch(ev);
|
||||
// Unknown CSI → drop. Do NOT insert raw bytes as text.
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton — one reader per process. */
|
||||
let singleton: StdinReader | null = null;
|
||||
|
||||
export function getStdinReader(): StdinReader {
|
||||
if (!singleton) singleton = new StdinReader();
|
||||
return singleton;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Reasonix visual theme.
|
||||
*
|
||||
* One source of truth for colors, glyphs, and decorative blocks so
|
||||
* the TUI reads as one designed surface instead of fifteen
|
||||
* components inventing their own palette. All colors are truecolor
|
||||
* hex; Ink degrades to the nearest 256-color / 8-color slot on
|
||||
* older terminals, so the worst case is still legible.
|
||||
*
|
||||
* Why a module rather than a context: tests import these strings,
|
||||
* Ink components consume them as `color={...}` props, and there's
|
||||
* no theming switch (light/dark) — one project, one palette.
|
||||
*/
|
||||
|
||||
/** Brand gradient — the same teal → cyan → blue → purple → fuchsia
|
||||
* sweep used by the REASONIX wordmark. Reused for accent bars,
|
||||
* progress fills, and decorative dividers. */
|
||||
export const GRADIENT: ReadonlyArray<string> = [
|
||||
"#5eead4", // teal
|
||||
"#67e8f9", // cyan
|
||||
"#7dd3fc", // sky
|
||||
"#93c5fd", // blue
|
||||
"#a5b4fc", // indigo
|
||||
"#c4b5fd", // violet
|
||||
"#d8b4fe", // purple
|
||||
"#f0abfc", // fuchsia
|
||||
];
|
||||
|
||||
/** Solid palette — semantic colors used for status, role, and
|
||||
* accent surfaces. Sourced from the Tailwind 400/500 row so the
|
||||
* tone is consistent with the gradient. */
|
||||
export const COLOR = {
|
||||
primary: "#67e8f9", // cyan-300
|
||||
accent: "#c4b5fd", // violet-300
|
||||
brand: "#5eead4", // teal-300
|
||||
|
||||
user: "#67e8f9", // user message glyph + bar
|
||||
assistant: "#86efac", // green-300, assistant glyph + bar
|
||||
tool: "#fcd34d", // amber-300, tool ok pill bg
|
||||
toolErr: "#fda4af", // rose-300, tool err pill bg
|
||||
info: "#94a3b8", // slate-400, info / dim
|
||||
warn: "#fbbf24", // amber-400
|
||||
err: "#f87171", // red-400
|
||||
ok: "#4ade80", // green-400
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Glyphs — one consistent alphabet for the whole TUI. Geometric
|
||||
* shapes (◇ ◆ ▣ ▥) make the role of a row readable at a glance
|
||||
* after one or two turns; arrows (› ▸ ▶) consistently mean
|
||||
* "selection / focus / next." Brand mark is reserved for the app
|
||||
* header.
|
||||
*/
|
||||
export const GLYPH = {
|
||||
brand: "◈",
|
||||
user: "◇",
|
||||
assistant: "◆",
|
||||
toolOk: "▣",
|
||||
toolErr: "▥",
|
||||
warn: "▲",
|
||||
err: "✦",
|
||||
arrow: "›",
|
||||
bullet: "·",
|
||||
bar: "▎",
|
||||
thinBar: "▏",
|
||||
block: "█",
|
||||
shade1: "░",
|
||||
shade2: "▒",
|
||||
shade3: "▓",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Render a horizontal gradient rule of the given width. Each cell
|
||||
* picks a color from the brand gradient interpolated across the
|
||||
* width, so at cols=80 the rule spans the full sweep. Used for
|
||||
* StatsPanel header/footer decoration and other "this section is
|
||||
* important" anchors.
|
||||
*/
|
||||
export function gradientCells(
|
||||
width: number,
|
||||
glyph: string = GLYPH.block,
|
||||
): Array<{ ch: string; color: string }> {
|
||||
const cells: Array<{ ch: string; color: string }> = [];
|
||||
if (width <= 0) return cells;
|
||||
const last = GRADIENT.length - 1;
|
||||
for (let i = 0; i < width; i++) {
|
||||
const t = width === 1 ? 0 : (i * last) / (width - 1);
|
||||
const lo = Math.floor(t);
|
||||
const hi = Math.min(last, lo + 1);
|
||||
// Pick the closer of the two anchor colors for this cell. Linear
|
||||
// hex blending could be fancier but the discrete steps already
|
||||
// read as a smooth fade at any reasonable width.
|
||||
const color = t - lo < 0.5 ? GRADIENT[lo]! : GRADIENT[hi]!;
|
||||
cells.push({ ch: glyph, color });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Tests for the CSI recovery boundary. Every keystroke that flows
|
||||
* through Ink's `useInput` runs through `recoverCsiTail` first, so a
|
||||
* regression here would silently re-break arrow keys / paste markers
|
||||
* / Shift+Tab on Windows ConPTY.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
STRIPPABLE_CSI_FRAGMENTS,
|
||||
recoverCsiTail,
|
||||
stripCsiFragments,
|
||||
} from "../src/cli/ui/key-normalize.js";
|
||||
|
||||
describe("recoverCsiTail — ESC-stripped fallbacks (Windows ConPTY)", () => {
|
||||
it("recovers arrow keys from bare CSI tails", () => {
|
||||
expect(recoverCsiTail("[A")).toEqual({ upArrow: true });
|
||||
expect(recoverCsiTail("[B")).toEqual({ downArrow: true });
|
||||
expect(recoverCsiTail("[C")).toEqual({ rightArrow: true });
|
||||
expect(recoverCsiTail("[D")).toEqual({ leftArrow: true });
|
||||
});
|
||||
|
||||
it("recovers page navigation tails", () => {
|
||||
expect(recoverCsiTail("[5~")).toEqual({ pageUp: true });
|
||||
expect(recoverCsiTail("[6~")).toEqual({ pageDown: true });
|
||||
});
|
||||
|
||||
it("recovers forward-delete tail", () => {
|
||||
expect(recoverCsiTail("[3~")).toEqual({ delete: true });
|
||||
});
|
||||
|
||||
it("recovers Shift+Tab tail", () => {
|
||||
expect(recoverCsiTail("[Z")).toEqual({ shift: true, tab: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverCsiTail — full CSI sequences (well-behaved terminals)", () => {
|
||||
it("also matches ESC-prefixed forms (in case parse-keypress merged them)", () => {
|
||||
expect(recoverCsiTail("\x1b[A")).toEqual({ upArrow: true });
|
||||
expect(recoverCsiTail("\x1b[5~")).toEqual({ pageUp: true });
|
||||
expect(recoverCsiTail("\x1b[Z")).toEqual({ shift: true, tab: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverCsiTail — pass-through cases", () => {
|
||||
it("returns null for unrelated input", () => {
|
||||
expect(recoverCsiTail("hello")).toBeNull();
|
||||
expect(recoverCsiTail("")).toBeNull();
|
||||
expect(recoverCsiTail("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for paste markers (handled separately by accumulator)", () => {
|
||||
expect(recoverCsiTail("[200~")).toBeNull();
|
||||
expect(recoverCsiTail("[201~")).toBeNull();
|
||||
expect(recoverCsiTail("\x1b[200~")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when Ink already populated a structured nav flag", () => {
|
||||
// Ink parsed `\x1b[A` correctly and set upArrow — don't second-guess
|
||||
// by also recovering from the raw `input` (the input would be ""
|
||||
// anyway in that case, but the guard is defence-in-depth).
|
||||
expect(recoverCsiTail("[A", { upArrow: true })).toBeNull();
|
||||
expect(recoverCsiTail("[C", { rightArrow: true })).toBeNull();
|
||||
expect(recoverCsiTail("[5~", { pageUp: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for already-structured Shift+Tab", () => {
|
||||
expect(recoverCsiTail("[Z", { shift: true, tab: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("plain `[` or `[A` substring within other text is NOT rewritten", () => {
|
||||
// The recover is exact-match on `input`. A user typing a Markdown
|
||||
// link `[A](url)` should not have it eaten as up-arrow.
|
||||
expect(recoverCsiTail("[A](url)")).toBeNull();
|
||||
expect(recoverCsiTail("[")).toBeNull();
|
||||
expect(recoverCsiTail("[ABC")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripCsiFragments", () => {
|
||||
it("removes paste markers (with and without ESC)", () => {
|
||||
expect(stripCsiFragments("\x1b[200~hello\x1b[201~")).toBe("hello");
|
||||
expect(stripCsiFragments("[200~hello[201~")).toBe("hello");
|
||||
});
|
||||
|
||||
it("removes recognised arrow tails embedded in text", () => {
|
||||
// An arrow tail that somehow ended up inside a paste blob — this
|
||||
// can happen if the user pastes content immediately followed by
|
||||
// an arrow key on a slow terminal. We scrub them out so no
|
||||
// garbage text lands in the prompt buffer.
|
||||
expect(stripCsiFragments("hello[Aworld")).toBe("helloworld");
|
||||
expect(stripCsiFragments("a\x1b[5~b")).toBe("ab");
|
||||
});
|
||||
|
||||
it("leaves non-fragment text alone", () => {
|
||||
expect(stripCsiFragments("hello world")).toBe("hello world");
|
||||
expect(stripCsiFragments("foo[](url)bar")).toBe("foo[](url)bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("STRIPPABLE_CSI_FRAGMENTS", () => {
|
||||
it("includes both ESC-prefixed and bare forms of each tail", () => {
|
||||
// Sanity check: every bare form has its ESC-prefixed sibling.
|
||||
for (const frag of STRIPPABLE_CSI_FRAGMENTS) {
|
||||
if (frag.startsWith("[")) {
|
||||
expect(STRIPPABLE_CSI_FRAGMENTS).toContain(`\x1b${frag}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -210,6 +210,19 @@ describe("processMultilineKey — cursor motion", () => {
|
||||
expect(processMultilineKey("abc", 2, { input: "\x1b[D" }).cursor).toBe(1);
|
||||
});
|
||||
|
||||
it("ESC-stripped arrow fallbacks (`[C`, `[D`, `[A`, `[B`) — Windows ConPTY case", () => {
|
||||
// PowerShell + ConPTY consumes the leading \x1b and routes the
|
||||
// remaining `[C` through useInput as plain text. Without the
|
||||
// ESC-less fallback, pressing right-arrow at end of a line would
|
||||
// insert literal `[C` instead of moving the cursor across the
|
||||
// newline boundary — which surfaced as "right arrow can't reach
|
||||
// line 2" on Windows.
|
||||
expect(processMultilineKey("ab\ncd", 2, { input: "[C" }).cursor).toBe(3);
|
||||
expect(processMultilineKey("ab\ncd", 3, { input: "[D" }).cursor).toBe(2);
|
||||
expect(processMultilineKey("", 0, { input: "[A" }).historyHandoff).toBe("prev");
|
||||
expect(processMultilineKey("", 0, { input: "[B" }).historyHandoff).toBe("next");
|
||||
});
|
||||
|
||||
it("↑ moves cursor to the previous line, preserving column when possible", () => {
|
||||
// line 0: "hello" (cols 0-5)
|
||||
// line 1: "world" (cols 0-5)
|
||||
@@ -371,6 +384,15 @@ describe("processMultilineKey — paste burst handling", () => {
|
||||
expect(r.submit).toBe(false);
|
||||
});
|
||||
|
||||
it("strips ESC-less paste markers (Windows PowerShell + ConPTY case)", () => {
|
||||
// Ink's parse-keypress eats the leading \x1b, leaving bare `[200~` /
|
||||
// `[201~` in `input`. Without the fallback strip the literal
|
||||
// `[201~` ends up inserted into the user's prompt buffer.
|
||||
const r = processMultilineKey("", 0, key({ input: "[200~hello\nworld[201~" }));
|
||||
expect(r.pasteRequest?.content).toBe("hello\nworld");
|
||||
expect(r.submit).toBe(false);
|
||||
});
|
||||
|
||||
it("real Enter (input='', return=true) still submits — not mistaken for paste", () => {
|
||||
const r = processMultilineKey("foo", 3, key({ input: "", return: true }));
|
||||
expect(r.submit).toBe(true);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Tests for the viewport-clipping helpers that PromptInput v2 uses
|
||||
* to render each logical line as exactly one visual row.
|
||||
*
|
||||
* The math has to agree with what Ink/Yoga would have rendered (had
|
||||
* we let it wrap), so consumers can reason about cell positions.
|
||||
* Wide CJK chars count as 2; ASCII is 1; control chars 0.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type PasteEntry,
|
||||
encodePasteSentinel,
|
||||
makePasteEntry,
|
||||
} from "../src/cli/ui/paste-sentinels.js";
|
||||
import { buildViewport, charCells, stringCells } from "../src/cli/ui/prompt-viewport.js";
|
||||
|
||||
describe("charCells", () => {
|
||||
it("ASCII printable = 1 cell", () => {
|
||||
expect(charCells("a")).toBe(1);
|
||||
expect(charCells("Z")).toBe(1);
|
||||
expect(charCells(" ")).toBe(1);
|
||||
});
|
||||
|
||||
it("control chars = 0 cells", () => {
|
||||
expect(charCells("\x00")).toBe(0);
|
||||
expect(charCells("\x1b")).toBe(0);
|
||||
expect(charCells("\x7f")).toBe(0);
|
||||
});
|
||||
|
||||
it("CJK ideographs = 2 cells", () => {
|
||||
expect(charCells("你")).toBe(2);
|
||||
expect(charCells("漢")).toBe(2);
|
||||
expect(charCells("日")).toBe(2);
|
||||
});
|
||||
|
||||
it("Hiragana / Katakana = 2 cells", () => {
|
||||
expect(charCells("あ")).toBe(2);
|
||||
expect(charCells("カ")).toBe(2);
|
||||
});
|
||||
|
||||
it("Hangul Syllables = 2 cells", () => {
|
||||
expect(charCells("한")).toBe(2);
|
||||
});
|
||||
|
||||
it("Fullwidth ASCII = 2 cells", () => {
|
||||
expect(charCells("a")).toBe(2);
|
||||
expect(charCells("1")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stringCells", () => {
|
||||
it("sums cells for mixed ASCII/CJK strings", () => {
|
||||
expect(stringCells("ab你好")).toBe(2 + 2 + 2);
|
||||
});
|
||||
|
||||
it("includes paste sentinel placeholder width", () => {
|
||||
const id = 0;
|
||||
const sentinel = encodePasteSentinel(id);
|
||||
const entry = makePasteEntry(id, "x".repeat(100));
|
||||
const map: ReadonlyMap<number, PasteEntry> = new Map([[id, entry]]);
|
||||
// sentinel char is 1 codepoint but renders as `[paste #1 · 1l · 100B]`
|
||||
// (label length is the placeholder width in cells).
|
||||
const totalWithoutPaste = stringCells("ab", map);
|
||||
const total = stringCells(`ab${sentinel}`, map);
|
||||
expect(total).toBeGreaterThan(totalWithoutPaste);
|
||||
// Match the formula in pasteSentinelLabel.
|
||||
const label = `[paste #${id + 1} · ${entry.lineCount}l · ${formatBytes(entry.charCount)}]`;
|
||||
expect(total - totalWithoutPaste).toBe(label.length);
|
||||
});
|
||||
});
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
// Mirror formatBytesShort from paste-sentinels (kept inline to
|
||||
// avoid a circular import in tests).
|
||||
if (n < 1000) return `${n}B`;
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}KB`;
|
||||
return `${(n / 1_000_000).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
describe("buildViewport — fits-in-budget fast path", () => {
|
||||
it("returns the whole line when it fits", () => {
|
||||
const vp = buildViewport("hello", 0, 80);
|
||||
expect(vp.segments).toEqual([{ kind: "text", text: "hello" }]);
|
||||
expect(vp.cursorCell).toBe(0);
|
||||
expect(vp.hiddenLeft).toBe(false);
|
||||
expect(vp.hiddenRight).toBe(false);
|
||||
});
|
||||
|
||||
it("places cursor at end-of-line correctly", () => {
|
||||
const vp = buildViewport("hello", 5, 80);
|
||||
expect(vp.cursorCell).toBe(5);
|
||||
});
|
||||
|
||||
it("returns null cursorCell when cursorCol is null", () => {
|
||||
const vp = buildViewport("hello", null, 80);
|
||||
expect(vp.cursorCell).toBeNull();
|
||||
});
|
||||
|
||||
it("CJK content cell-counts correctly", () => {
|
||||
// "你好" is 4 cells, fits in 80.
|
||||
const vp = buildViewport("你好", 1, 80);
|
||||
expect(vp.segments).toEqual([{ kind: "text", text: "你好" }]);
|
||||
expect(vp.cursorCell).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildViewport — clipping (cursor-bearing line)", () => {
|
||||
it("cursor near start of long line: window starts at 0, right-edge marked hidden", () => {
|
||||
const longLine = "a".repeat(200);
|
||||
const vp = buildViewport(longLine, 5, 40);
|
||||
expect(vp.hiddenLeft).toBe(false);
|
||||
expect(vp.hiddenRight).toBe(true);
|
||||
// Cursor cell still computable.
|
||||
expect(vp.cursorCell).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("cursor at end of long line: window slides right, left-edge marked hidden", () => {
|
||||
const longLine = "a".repeat(200);
|
||||
const vp = buildViewport(longLine, 200, 40);
|
||||
expect(vp.hiddenLeft).toBe(true);
|
||||
expect(vp.hiddenRight).toBe(false);
|
||||
});
|
||||
|
||||
it("cursor in middle of long line: both sides hidden, cursor in viewport", () => {
|
||||
const longLine = "a".repeat(200);
|
||||
const vp = buildViewport(longLine, 100, 40);
|
||||
expect(vp.hiddenLeft).toBe(true);
|
||||
expect(vp.hiddenRight).toBe(true);
|
||||
// Sum of segment cells should be <= visibleCells - 2 (markers).
|
||||
const visible = vp.segments.reduce(
|
||||
(sum, s) => sum + (s.kind === "text" ? s.text.length : s.label.length),
|
||||
0,
|
||||
);
|
||||
expect(visible).toBeLessThanOrEqual(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildViewport — clipping (static line, no cursor)", () => {
|
||||
it("clips long static line at the right with hiddenRight=true", () => {
|
||||
const vp = buildViewport("a".repeat(100), null, 30);
|
||||
expect(vp.hiddenLeft).toBe(false);
|
||||
expect(vp.hiddenRight).toBe(true);
|
||||
expect(vp.cursorCell).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildViewport — paste sentinels", () => {
|
||||
it("renders a paste sentinel as one paste segment with its label", () => {
|
||||
const id = 0;
|
||||
const sentinel = encodePasteSentinel(id);
|
||||
const entry = makePasteEntry(id, "hello\nworld");
|
||||
const map = new Map([[id, entry]]);
|
||||
const vp = buildViewport(`a${sentinel}b`, 1, 80, map);
|
||||
expect(vp.segments).toHaveLength(3);
|
||||
expect(vp.segments[0]).toEqual({ kind: "text", text: "a" });
|
||||
expect(vp.segments[1]).toMatchObject({ kind: "paste", id });
|
||||
expect(vp.segments[2]).toEqual({ kind: "text", text: "b" });
|
||||
});
|
||||
|
||||
it("missing paste entry renders the `(missing)` placeholder", () => {
|
||||
const sentinel = encodePasteSentinel(7);
|
||||
const vp = buildViewport(sentinel, 0, 80);
|
||||
expect(vp.segments).toHaveLength(1);
|
||||
expect(vp.segments[0]).toMatchObject({ kind: "paste", id: 7, label: "[paste #8 · (missing)]" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Unit tests for the raw stdin reader's CSI parser. Drives the
|
||||
* machine via `feed()` (the test-only injection point) so we can
|
||||
* exercise every state transition without touching real stdin.
|
||||
*
|
||||
* These tests are the safety net for the input layer: any regression
|
||||
* here means a class of keyboard bug returns. The cases below mirror
|
||||
* every symptom we shipped a patch for in 0.7.x.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type KeyEvent, StdinReader } from "../src/cli/ui/stdin-reader.js";
|
||||
|
||||
function setup() {
|
||||
const reader = new StdinReader();
|
||||
const events: KeyEvent[] = [];
|
||||
reader.subscribe((ev) => events.push(ev));
|
||||
return { reader, events };
|
||||
}
|
||||
|
||||
describe("StdinReader — CSI sequences (well-behaved)", () => {
|
||||
it("parses arrow keys", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b[A");
|
||||
reader.feed("\x1b[B");
|
||||
reader.feed("\x1b[C");
|
||||
reader.feed("\x1b[D");
|
||||
expect(events).toEqual([
|
||||
{ input: "", upArrow: true },
|
||||
{ input: "", downArrow: true },
|
||||
{ input: "", rightArrow: true },
|
||||
{ input: "", leftArrow: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses page-nav keys", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b[5~");
|
||||
reader.feed("\x1b[6~");
|
||||
reader.feed("\x1b[3~");
|
||||
expect(events).toEqual([
|
||||
{ input: "", pageUp: true },
|
||||
{ input: "", pageDown: true },
|
||||
{ input: "", delete: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses Shift+Tab as `\\x1b[Z`", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b[Z");
|
||||
expect(events).toEqual([{ input: "", shift: true, tab: true }]);
|
||||
});
|
||||
|
||||
it("parses SS3 arrow forms (`\\x1bO<letter>`)", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1bOA");
|
||||
reader.feed("\x1bOC");
|
||||
expect(events).toEqual([
|
||||
{ input: "", upArrow: true },
|
||||
{ input: "", rightArrow: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops unknown CSI silently (no garbage text inserted)", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b[42m"); // SGR — irrelevant to us, skip
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StdinReader — CSI sequences (Windows ConPTY ESC-stripped)", () => {
|
||||
it("recovers arrow keys when leading ESC is missing", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("[A");
|
||||
reader.feed("[C");
|
||||
expect(events).toEqual([
|
||||
{ input: "", upArrow: true },
|
||||
{ input: "", rightArrow: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recovers Shift+Tab from bare `[Z`", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("[Z");
|
||||
expect(events).toEqual([{ input: "", shift: true, tab: true }]);
|
||||
});
|
||||
|
||||
it("recovers PgUp / PgDn / Delete from bare CSI tails", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("[5~");
|
||||
reader.feed("[6~");
|
||||
reader.feed("[3~");
|
||||
expect(events).toEqual([
|
||||
{ input: "", pageUp: true },
|
||||
{ input: "", pageDown: true },
|
||||
{ input: "", delete: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StdinReader — single-byte keys", () => {
|
||||
it("Enter / Tab / Backspace fire structured events", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\r");
|
||||
reader.feed("\t");
|
||||
reader.feed("\x7f");
|
||||
reader.feed("\b");
|
||||
expect(events).toEqual([
|
||||
{ input: "", return: true },
|
||||
{ input: "", tab: true },
|
||||
{ input: "", backspace: true },
|
||||
{ input: "", backspace: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("Ctrl+C surfaces as `{input:'c', ctrl:true}`", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x03");
|
||||
expect(events).toEqual([{ input: "c", ctrl: true }]);
|
||||
});
|
||||
|
||||
it("Ctrl+J (LF, 0x0A) surfaces distinctly from Enter so multiline can insert a newline", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\r");
|
||||
reader.feed("\n");
|
||||
expect(events).toEqual([
|
||||
{ input: "", return: true },
|
||||
{ input: "j", ctrl: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("modifyOtherKeys / kitty Shift+Enter sequences surface as `{return:true, shift:true}`", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b[27;2;13~");
|
||||
reader.feed("\x1b[13;2u");
|
||||
expect(events).toEqual([
|
||||
{ input: "", return: true, shift: true },
|
||||
{ input: "", return: true, shift: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("Ctrl+letter codes 0x01–0x1A map to a..z with ctrl flag", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x01"); // Ctrl+A
|
||||
reader.feed("\x05"); // Ctrl+E
|
||||
reader.feed("\x15"); // Ctrl+U
|
||||
reader.feed("\x17"); // Ctrl+W
|
||||
expect(events.map((e) => ({ input: e.input, ctrl: e.ctrl }))).toEqual([
|
||||
{ input: "a", ctrl: true },
|
||||
{ input: "e", ctrl: true },
|
||||
{ input: "u", ctrl: true },
|
||||
{ input: "w", ctrl: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("printable runs are coalesced into one event", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("hello");
|
||||
expect(events).toEqual([{ input: "hello" }]);
|
||||
});
|
||||
|
||||
it("CJK printable runs are coalesced", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("你好世界");
|
||||
expect(events).toEqual([{ input: "你好世界" }]);
|
||||
});
|
||||
|
||||
it("printable run breaks at a CSI / control byte", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("ab\rcd");
|
||||
expect(events).toEqual([{ input: "ab" }, { input: "", return: true }, { input: "cd" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StdinReader — bracketed paste", () => {
|
||||
it("emits a single paste event for content between `\\x1b[200~` and `\\x1b[201~`", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b[200~hello\nworld\x1b[201~");
|
||||
expect(events).toEqual([{ input: "hello\nworld", paste: true }]);
|
||||
});
|
||||
|
||||
it("paste content is collected across multiple feed calls (chunked stdin)", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b[200~hello\n");
|
||||
reader.feed("middle\n");
|
||||
reader.feed("end\x1b[201~");
|
||||
expect(events).toEqual([{ input: "hello\nmiddle\nend", paste: true }]);
|
||||
});
|
||||
|
||||
it("ESC-stripped paste markers (ConPTY) — bare `[200~`/`[201~` works too", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("[200~paste content[201~");
|
||||
expect(events).toEqual([{ input: "paste content", paste: true }]);
|
||||
});
|
||||
|
||||
it("typing AFTER a paste is parsed as keystrokes again", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b[200~pasted\x1b[201~hello");
|
||||
expect(events).toEqual([{ input: "pasted", paste: true }, { input: "hello" }]);
|
||||
});
|
||||
|
||||
it("printable runs do not eat a following paste-start prefix", () => {
|
||||
const { reader, events } = setup();
|
||||
// `ab` then bare paste-start then content then end.
|
||||
reader.feed("ab[200~stuff[201~");
|
||||
expect(events).toEqual([{ input: "ab" }, { input: "stuff", paste: true }]);
|
||||
});
|
||||
|
||||
it("printable runs do not eat a following ESC-less arrow tail", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("ab[Ccd");
|
||||
expect(events).toEqual([{ input: "ab" }, { input: "", rightArrow: true }, { input: "cd" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StdinReader — ESC ambiguity timer", () => {
|
||||
it("standalone Esc (no follow-up byte arrives) eventually fires escape:true", async () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b");
|
||||
// The reader schedules a 250ms timer. Wait it out.
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
expect(events).toEqual([{ input: "", escape: true }]);
|
||||
});
|
||||
|
||||
it("ESC followed by a CSI within the timer window resolves to the CSI event", async () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1b");
|
||||
// Some delay — but less than 250ms.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
reader.feed("[A");
|
||||
// No need to wait; the CSI completes the sequence immediately.
|
||||
expect(events).toEqual([{ input: "", upArrow: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StdinReader — ESC + char (Alt+key)", () => {
|
||||
it("ESC followed by a non-CSI char fires meta:true", () => {
|
||||
const { reader, events } = setup();
|
||||
reader.feed("\x1bx");
|
||||
expect(events).toEqual([{ input: "x", meta: true }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user