refactor(input): replace Ink useInput with our own stdin reader

The 0.7.x bug parade (paste markers leaking, arrows not crossing
newlines, Shift+Tab broken, CJK ghost rows) traced back to two upstream
limitations we couldn't patch around forever:

  1. Ink's `parse-keypress` uses a 100 ms intra-CSI timeout that is too
     short for Windows ConPTY. Sequences like `\x1b[A` arrive split
     across reads with > 100 ms gaps, so the lone `\x1b` fires as
     `key.escape:true` and `[A` lands in `input` as plain text.

  2. Ink/Yoga measures cell width with `string-width`, but on CJK
     terminals some glyphs render at a different cell count than that
     library reports. The terminal wraps to a 2nd visual row that
     Ink's `eraseLines` doesn't account for, so each render leaks a
     row into permanent scrollback.

Patching consumers one at a time was a Whac-A-Mole. This change
replaces the entire input layer:

  - **`stdin-reader.ts`** — owns `process.stdin`, parses bytes with
    our own state machine. ESC ambiguity timer is 250 ms (vs. Ink's
    100). ESC-stripped CSI tails (`[A`, `[5~`, `[Z`, `[200~`) are
    recognised at idle-state lookahead. SS3 (`\x1bOA`) supported. Drops
    unknown CSI silently rather than inserting bytes as text.

  - **`keystroke-context.tsx`** — React Context that owns the singleton
    StdinReader and fans events out to subscribers via `useKeystroke`.
    Replaces every `useInput` import in our tree.

  - **`prompt-viewport.ts`** — pure helpers that slice each logical
    prompt line into exactly one visual row. The cursor's window
    slides so it stays visible; `‹` / `›` markers indicate clipped
    content. CJK cell widths handled explicitly so our math matches
    the terminal grid (no more `string-width` disagreement with the
    real renderer).

  - **PromptInput v2** — rewritten on top of these. No `useTick` (no
    cursor blink — the 8 Hz repaint was amplifying the eraseLines
    leak). One Box row per logical line, fixed visual height, viewport
    clipping for content longer than the terminal width.

  - **App / Select / EditConfirm / PlanRefineInput** migrated to
    `useKeystroke`. Wizard / DiffApp / ReplayApp keep Ink's `useInput`
    — separate render calls, no provider in their tree, no conflict.
    SessionPicker (which uses our migrated SingleSelect) gets its own
    KeystrokeProvider wrapper in chat.tsx.

  - As long as no `useInput` is called inside the Ink tree, Ink itself
    never attaches a stdin listener, so our reader is the sole consumer
    of stdin. No race with parse-keypress.

41 new tests covering every CSI tail, paste accumulator across chunks,
ConPTY ESC-stripping, viewport clipping for CJK / paste sentinels.
1283 → 1324 tests, lint + typecheck clean.
This commit is contained in:
esengine committed 2026-04-25 10:50:08 -07:00
1 parent 634f8e6028
commit 49d4648b07
12 files changed
+1904 -683

No files matched your search

+21 -14
View File
@@ -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 {
@@ -104,20 +105,26 @@ function Root({
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);
}}
/>
// SessionPicker uses our SingleSelect, which subscribes via
// useKeystroke — that needs a KeystrokeProvider above it.
// App has its own provider; the picker phase needs a separate
// wrapper so the singleton reader gets started/subscribed.
<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>
);
}
+227 -221
View File
@@ -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 {
@@ -57,7 +57,7 @@ import { SlashSuggestions } from "./SlashSuggestions.js";
import { StatsPanel } from "./StatsPanel.js";
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
import { describeRepair, formatEditResults, formatPendingPreview } from "./edit-history.js";
import { recoverCsiTail } from "./key-normalize.js";
import { KeystrokeProvider, 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";
@@ -708,15 +708,19 @@ 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((rawInput, rawKey) => {
// CSI recovery — same boundary that PromptInput uses. On Windows
// ConPTY, `\x1b[Z` (Shift+Tab) and `[A`-style arrow tails arrive
// with the leading ESC eaten; without recovery the structured
// flags Ink populates would miss them and the global hotkey
// bindings below would silently ignore the keystroke.
const recovered = recoverCsiTail(rawInput, rawKey);
const chKey = recovered ? "" : rawInput;
const key = recovered ? { ...rawKey, ...recovered } : rawKey;
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;
}
if (key.escape && busy) {
if (abortedThisTurn.current) return;
abortedThisTurn.current = true;
@@ -2511,102 +2515,103 @@ export function App({
);
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>
{/*
<KeystrokeProvider>
<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>
{/*
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
@@ -2614,128 +2619,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>
</KeystrokeProvider>
);
}
+6 -6
View File
@@ -1,8 +1,8 @@
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 { recoverCsiTail } from "./key-normalize.js";
import { useKeystroke } from "./keystroke-context.js";
/**
* Choice surfaced to the interceptor when the user resolves the modal:
@@ -74,10 +74,10 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
// keypress drove the offset past the new ceiling.
const effectiveScroll = Math.min(scroll, maxScroll);
useInput((rawInput, rawKey) => {
const recovered = recoverCsiTail(rawInput, rawKey);
const input = recovered ? "" : rawInput;
const key = recovered ? { ...rawKey, ...recovered } : rawKey;
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.
+14 -7
View File
@@ -14,8 +14,9 @@
* the picker without resuming.
*/
import { Box, Text, useInput } from "ink";
import { Box, Text } from "ink";
import React, { useState } from "react";
import { useKeystroke } from "./keystroke-context.js";
export interface PlanRefineInputProps {
/**
@@ -37,22 +38,28 @@ 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);
}
});
+357 -416
View File
@@ -1,63 +1,39 @@
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 { useTick } from "./ticker.js";
import { type Segment, buildViewport, stringCells } from "./prompt-viewport.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.
* 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.
*
* The fallback variants without the leading `\x1b` cover terminals
* (Windows PowerShell + ConPTY + Ink, in particular) where Ink's
* `parse-keypress` consumes the ESC byte but routes the remaining
* `[200~` / `[201~` into the next `useInput` event as plain text.
* Without these fallbacks the literal `[201~` ends up inserted into
* the user's prompt buffer.
* 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.
*
* The cursor is a static `▌` block — no blink. Blinking required a
* 120ms ticker that re-rendered the whole tree, which on fragile
* terminals interacted badly with Ink's eraseLines miscount.
*/
const PASTE_START_MARKER = "\u001b[200~";
const PASTE_END_MARKER = "\u001b[201~";
const PASTE_START_FALLBACK = "[200~";
const PASTE_END_FALLBACK = "[201~";
function findPasteStart(input: string): { idx: number; len: number } | null {
const a = input.indexOf(PASTE_START_MARKER);
if (a !== -1) return { idx: a, len: PASTE_START_MARKER.length };
const b = input.indexOf(PASTE_START_FALLBACK);
if (b !== -1) return { idx: b, len: PASTE_START_FALLBACK.length };
return null;
}
function findPasteEnd(input: string): { idx: number; len: number } | null {
const a = input.indexOf(PASTE_END_MARKER);
if (a !== -1) return { idx: a, len: PASTE_END_MARKER.length };
const b = input.indexOf(PASTE_END_FALLBACK);
if (b !== -1) return { idx: b, len: PASTE_END_FALLBACK.length };
return null;
}
/**
* 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.
*/
const PASTE_MERGE_WINDOW_MS = 30;
export interface PromptInputProps {
value: string;
@@ -75,18 +51,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,
@@ -97,220 +61,105 @@ 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 end = findPasteEnd(input);
if (end === null) {
pasteAccumRef.current += input;
return;
}
const content = pasteAccumRef.current + input.slice(0, end.idx);
pasteAccumRef.current = null;
registerPaste(content);
return;
}
const start = findPasteStart(input);
if (start !== null) {
const afterStart = input.slice(start.idx + start.len);
const end = findPasteEnd(afterStart);
if (end !== null) {
// Whole paste in one read — register and we're done.
registerPaste(afterStart.slice(0, end.idx));
} 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 continuationIndent = narrow ? " " : " ";
const prefixCells = narrow ? 2 : 6;
// 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";
@@ -319,78 +168,50 @@ export function PromptInput({
: (placeholder ?? placeholderActive);
const lines = value.length > 0 ? value.split("\n") : [""];
const borderColor = disabled ? "gray" : "cyan";
const accentColor = disabled ? "gray" : "cyan";
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 (
// Borderless layout. Bordered Boxes amplify Ink's eraseLines
// miscount on fragile Windows terminals — every render that wraps
// pushes a ghost top-border into scrollback. Without a border the
// visual hierarchy comes from the bold colored prefix (`you ›`)
// and the cursor block; row-by-row stacking via `flexDirection`
// gives multi-line composition the same shape it had inside the
// box.
<Box paddingX={1} flexDirection="column">
<Box flexDirection="column" paddingX={1}>
{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`
// biome-ignore lint/suspicious/noArrayIndexKey: stable — collapse markers derive from a fixed sliding window
<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>
<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 line = item.line;
const isFirst = i === 0;
const showPlaceholder = isFirst && value.length === 0;
const isCursorLine = i === cursorLine;
const showPlaceholder = isFirst && value.length === 0;
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} />
)}
</Box>
<PromptLine
key={`ln-${i}`}
line={line}
isFirst={isFirst}
isCursorLine={isCursorLine && !disabled}
cursorCol={isCursorLine ? cursorCol : null}
showPlaceholder={showPlaceholder}
placeholderText={effectivePlaceholder}
promptPrefix={promptPrefix}
continuationIndent={continuationIndent}
visibleCells={visibleCells}
accentColor={accentColor}
pastes={pastesRef.current}
disabled={disabled === true}
/>
);
})}
{showHugeBufferHints && !disabled ? (
@@ -401,11 +222,6 @@ export function PromptInput({
</Text>
</Box>
) : null}
{/* Disabled-state Esc hint kept inside the bordered Box so the
live region's structure is one Box, not a `<>`-wrapped pair.
The fragment shape was a structural change with no layout
benefit — moving back here matches the simpler shape from
before 0.7.2, which the duplicating-borders Ink quirk amplified. */}
{disabled ? (
<Box>
<Text dimColor>{continuationIndent}</Text>
@@ -416,23 +232,248 @@ export function PromptInput({
);
}
// ── PromptLine ────────────────────────────────────────────────────
interface PromptLineProps {
line: string;
isFirst: boolean;
isCursorLine: boolean;
cursorCol: number | null;
showPlaceholder: boolean;
placeholderText: string;
promptPrefix: string;
continuationIndent: string;
visibleCells: number;
accentColor: "cyan" | "gray";
pastes: ReadonlyMap<number, PasteEntry>;
disabled: boolean;
}
function PromptLine({
line,
isFirst,
isCursorLine,
cursorCol,
showPlaceholder,
placeholderText,
promptPrefix,
continuationIndent,
visibleCells,
accentColor,
pastes,
disabled,
}: PromptLineProps) {
if (showPlaceholder) {
return (
<Box>
<Text bold color={accentColor}>
{promptPrefix}
</Text>
{!disabled ? <Text color={accentColor}>▌</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>
{isFirst ? (
<Text bold color={accentColor}>
{promptPrefix}
</Text>
) : (
<Text dimColor>{continuationIndent}</Text>
)}
{viewport.hiddenLeft ? (
<Text color="gray" dimColor>
‹
</Text>
) : null}
<ViewportContent
segments={viewport.segments}
cursorCell={isCursorLine ? viewport.cursorCell : null}
accentColor={accentColor}
/>
{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,
}: {
segments: Segment[];
cursorCell: number | null;
accentColor: "cyan" | "gray";
}) {
// 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.
out.push(
<Text key={`p-${i}-cursor`} color="magenta" bold inverse>
{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 color={accentColor}>
{split.atCursor}
</Text>,
);
} else {
// Cursor sits past the segment's last char (end-of-text in this
// segment). Render block here.
out.push(
<Text key={`t-${i}-c-eol`} color={accentColor}>
▌
</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}>
▌
</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>;
}
return (
<Text key={`s-${key}`} color="magenta" 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;
@@ -441,9 +482,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);
@@ -460,100 +498,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 -18
View File
@@ -9,9 +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 { recoverCsiTail } from "./key-normalize.js";
import { useKeystroke } from "./keystroke-context.js";
export interface SelectItem<V extends string = string> {
/** Stable identifier — returned to caller on submit. */
@@ -51,17 +51,16 @@ export function SingleSelect<V extends string>({
);
const [index, setIndex] = useState(initialIndex === -1 ? 0 : initialIndex);
useInput((rawInput, rawKey) => {
const recovered = recoverCsiTail(rawInput, rawKey);
const key = recovered ? { ...rawKey, ...recovered } : rawKey;
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();
}
});
@@ -107,15 +106,13 @@ export function MultiSelect<V extends string>({
});
const [selected, setSelected] = useState<Set<V>>(new Set(initialSelected));
useInput((rawInput, rawKey) => {
const recovered = recoverCsiTail(rawInput, rawKey);
const input = recovered ? "" : rawInput;
const key = recovered ? { ...rawKey, ...recovered } : rawKey;
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) => {
@@ -124,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();
}
});
+120
View File
@@ -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 };
}
+318
View File
@@ -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;
}
+436
View File
@@ -0,0 +1,436 @@
/**
* 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 } },
];
/** 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.
if (ch === "\r" || ch === "\n") {
this.dispatch({ input: "", return: 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;
}
+1 -1
View File
@@ -7,8 +7,8 @@
import { describe, expect, it } from "vitest";
import {
recoverCsiTail,
STRIPPABLE_CSI_FRAGMENTS,
recoverCsiTail,
stripCsiFragments,
} from "../src/cli/ui/key-normalize.js";
+167
View File
@@ -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)]" });
});
});
+222
View File
@@ -0,0 +1,222 @@
/**
* 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+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 }]);
});
});