mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
634f8e6028
commit
49d4648b07
12 files changed
+1904
-683
No files matched your search
+21
-14
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
recoverCsiTail,
|
||||
STRIPPABLE_CSI_FRAGMENTS,
|
||||
recoverCsiTail,
|
||||
stripCsiFragments,
|
||||
} from "../src/cli/ui/key-normalize.js";
|
||||
|
||||
|
||||
@@ -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,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 }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user