Compare commits

...
26 Commits
Author SHA1 Message Date
esengine 73660729ab chore(release): 0.7.5
Visual overhaul wave on top of 0.7.4. Highlights:

  - theme module: brand teal→fuchsia gradient, semantic palette, glyph
    set + gradientCells helper used across every animated surface.
  - StatsPanel: gradient header/footer rules, neon-flowing wordmark,
    threshold-colored ▰▰▰▱ context bar, icon-prefixed metric cells.
    Animations gate at cols ≥ 100 to avoid Ink eraseLines miscount.
  - PromptInput: animated left bar (vertical gradient flow), blinking
    cursor, fuchsia-bg paste sentinels, [Ctrl+J] newline hint.
  - EventLog: full-height role bands on user + assistant + edit-file
    diffs, gradient turn separators, role-pilled headers (model in
    bg-green pill, tools in yellow/red bg pill), violet bg-pill for
    reasoning previews + diff hunks, pill-ified branch/plan-state/
    stats/step-progress/plan-resumed/plan-replay rows, semantic info
    icons (▸/▲/✓/✗/↻ auto-detected from text).
  - Markdown: H1 cyan / H2 violet / H3 fuchsia bg-pill headings,
    round-bordered code blocks with language pill, brand-color
    blockquote bar.
  - Modals: shared ModalCard frame across choice / plan / shell /
    edit / checkpoint / revise / refine — each with its own accent
    so the user learns by sight.
  - LiveRows: solid-bg pill ModeStatusBar (vim modeline feel),
    streaming phase pills (WAITING/THINKING/DISPATCH/WRITING),
    UndoBanner + SubagentRow + OngoingToolRow + StatusRow all
    pill-ified.
  - Welcome card: gradient rules + sectioned hint card on first run.
  - Misc: clear viewport + scrollback at startup, fix WelcomeBanner
    gate (was never showing because the session info row already
    populated history), markdown headings drop literal '###'.
2026-04-25 22:06:48 -07:00
esengine 701dbcc32b feat(ui): clear terminal viewport + scrollback at startup
Before Ink mounts, write \x1b[2J\x1b[3J\x1b[H so the user lands on a
fresh canvas — no leftover shell prompt, last command output, or
unrelated terminal history bleeding into the first frame. Same
sequence the /clear slash command already uses. TTY-gated so CI /
piped runs don't get raw escapes in their captured stdout.
2026-04-25 22:05:37 -07:00
esengine 1603479a3d fix(ui): gate animations + secondary pills on cols≥100 to stop narrow-term flashing
Root cause: at narrow widths the StatsPanel header (wordmark +
version + model + N pills) and the PromptInput's typed line both
wrap to multiple visual rows. Ink's eraseLines counts logical rows,
not visible rows, so each tick (~120 ms re-render driven by the
wordmark / gradient bar / cursor blink animations) miscounts the
erase and leaves ghost rows in scrollback. Several seconds in the
screen looks like it's continuously scrolling — the user-reported
'screen flashing' symptom.

Fix:

  - StatsPanel — added `animate = columns >= 100` gate. Below the
    threshold the gradient rules + wordmark freeze (no per-tick
    color shift), and the secondary pills (harvest / branch×N /
    reasoning effort) hide so the header always fits one row. The
    primary pills (PLAN / AUTO / REVIEW / ⇧ PRO) and metrics row
    stay; only the optional decorations are dropped.
  - PromptInput — same gate on the prompt. Below 100 cols the
    left-bar gradient freezes and the cursor stops blinking. Above
    100 cols nothing changes.
  - 100-col threshold matches NARROW_BREAKPOINT closely (120) but is
    a hair tighter — the panel can survive 80–100 cols without
    wrapping if we drop the secondary content.

The bordered Boxes added recently (assistant left band, user left
band, edit-file diff band) live inside `<Static>`, render once per
event, never re-render, so they don't contribute to the per-tick
miscount.
2026-04-25 22:01:06 -07:00
esengine d263068bb2 fix(ui): markdown headings — drop literal '###', H3 gets fuchsia pill
User reported H3 was showing the literal sigils ('### 📖 代码探索')
inside assistant output, which defeats the heading semantic. Fixed:

  - H1 cyan pill, H2 violet pill, H3 fuchsia pill, H4+ bold accent
    with a leading ▸ marker.
  - Sigils (#, ##, ###) are stripped at the pill — the level chooses
    the bg color, not the title text. Three tiers walk down the
    brand gradient so the heading tree reads at a glance.
  - H4+ kept as bold-only (no bg) so deeply nested sections don't
    visually overpower body content.
2026-04-25 21:55:59 -07:00
esengine 83768b92e4 feat(ui): diff hunk pill, brand-color blockquote bar, paste sentinel pill
- EditFileDiff — hunk header is now a violet bg-pill (` @@ -10,3
    +10,5 @@ `) instead of a plain cyan-bold one-liner. Body lines
    have a colored gutter glyph (− for removed, + for added)
    followed by tinted body text, so add/remove rows scan as
    distinct lanes rather than colored prose.
  - Markdown blockquote — left bar now uses the brand violet
    (#c4b5fd) instead of dim gray, so quoted content carries
    visual weight that matches the rest of the palette.
  - Paste sentinel — was a magenta bold token (`[paste #1 · 12l ·
    1.4KB]`); now a solid fuchsia bg-pill so the collapsed paste
    block is unambiguous. Matches the mode/tool/badge pill idiom
    used everywhere else.
2026-04-25 21:53:35 -07:00
esengine 9174c1ba59 feat(ui): cursor blinks, refine input wrapped in ModalCard with mode-aware accent
- PromptInput cursor — was a static ▌ block; now toggles ▌↔space
    every 4 ticks (~480 ms half-period) via the same ticker that
    flows the bar gradient. Inverse-char cursor (mid-line) and
    paste-sentinel cursor toggle their `inverse` prop in lockstep
    so the character stays legible during the cursor's "off"
    half-cycle. Disabled state freezes the cursor solid so the
    busy-turn prompt reads as "frozen" not "dead".
  - PlanRefineInput — wrapped in the shared ModalCard frame with
    mode-specific accent: cyan for approve, amber for refine /
    checkpoint-revise, fuchsia for choice-custom. Each accent
    matches the related confirm modal so users learn "amber =
    plan adjustment", "fuchsia = choice flow" by sight. Inline
    cursor blinks too, on the same tick.
  - Docstring on PromptInput updated — the old "no blink" comment
    is no longer accurate.
2026-04-25 21:50:22 -07:00
esengine 95c978722a feat(ui): plan-resumed/replay/step badges, error/warn pills, modal frames everywhere
- step-progress event row — replaced the green ✓ glyph with a
    solid-bg ` ✓ STEP ` pill plus colored counter, body and notes
    indented under for visual nesting.
  - plan-resumed event row — opens with a ` ↻ RESUMED PLAN ` cyan
    pill + bold counter; summary + step list flow underneath.
  - plan-replay event row — slate ` ⏪ REPLAY ` pill mirroring the
    resumed shape, so live and archived plans use the same idiom.
  - error / warning event rows — now use the same pill idiom
    (` ✦ ERROR ` rose, ` ▲ WARN ` amber) instead of one colored
    glyph + colored body.
  - PlanCheckpointConfirm + PlanReviseConfirm modals — wrapped in
    the shared ModalCard frame (green ✓ checkpoint, amber ✏
    revision). All four confirm-style modals now share the same
    top/bottom rule + bg-pill title visual language: choice
    (magenta), plan (cyan), shell (red), edit (yellow / green NEW),
    checkpoint (green), revision (amber). User learns the modal
    type by accent before reading the title.
2026-04-25 21:45:38 -07:00
esengine 37e741afa1 feat(ui): plan state pills, branch block redesign, semantic info icons
- PlanStateBlock — labels are now solid-bg pills (` subgoals 3 `,
    ` hypotheses 2 `, ` uncertainties 1 `, ` rejected 4 `) with
    truecolor hex backgrounds matching the role palette (cyan /
    green / amber / slate). Items render to the right of the pill.
  - BranchBlock — replaced the dim blue one-liner with a stacked
    layout: blue ` ⎇ BRANCH ×N ` pill on the header row, then
    one row per sample beneath with the chosen sample bolded and
    arrow-marked.
  - StatsLine (assistant footer) — was a flat dim row; now uses
    threshold-colored cache % (green/amber/rose), cyan/violet for
    in/out token counts, green for cost, with mid-dot separators.
  - Info rows — auto-detect tone from the leading glyph: ▲/⚠ →
    warn (amber), ✓ → ok (green), ✗/✖ → err (rose), ↻ → reload
    (cyan), default ▸ → info (slate). The glyph is colored-bold,
    body stays dim. Existing info messages with embedded prefixes
    pick up their semantic without callers changing anything.
2026-04-25 21:40:56 -07:00
esengine 526e3edc97 feat(ui): plan-step status pills, streaming phase pills, undo + subagent badges
- PlanStepList — replaced the colored ✓/▶/—/space glyph column
    with solid-bg status pills: ` ✓ DONE ` (green) / ` ▶ RUN  `
    (cyan) / ` — SKIP ` (slate) / ` ☐ PEND ` (slate dim). Header
    row now shows percent done. Focus row gets bold + accent
    arrow + bold title. Reads at a glance from across the screen.
  - StreamingAssistant — phase header is now a solid-bg pill
    (` WAITING ` amber, ` THINKING ` violet, ` DISPATCH ` fuchsia,
    ` WRITING ` green) instead of a long colored sentence. Pre-
    first-byte / reasoning-only / tool-call-only hints get
    paddingLeft so they sit visually under the pill. Same info
    density, far better visual hierarchy.
  - UndoBanner — replaced the magenta-text version with a violet
    bg-pill header (` ✓ AUTO-APPLIED N/M `) + a cyan key-pill on
    the `u` shortcut. Countdown turns red bold in the last second.
  - SubagentRow — violet bg-pill for the ⌬ subagent label,
    paddingLeft={3} so it visually nests under the parent
    OngoingToolRow when both are visible.
2026-04-25 21:33:14 -07:00
esengine 60024a5223 feat(ui): markdown headings get bg pills, modals share a card frame
Five surfaces touched, all visible during normal coding:

  - Markdown H1 → solid cyan-bg pill (` # title `).
  - Markdown H2 → solid violet-bg pill (` ## title `).
  - Markdown H3 → bold fuchsia ### with bold body, no bg (avoids
    visual overpower at the third nest level).
  - Modals — ChoiceConfirm, PlanConfirm, ShellConfirm, EditConfirm
    all wrap in a new <ModalCard> primitive: top accent rule, bg-pill
    title row with optional icon + dim subtitle, content area, bottom
    accent rule. Each modal keeps its own accent color so the user
    learns by sight: magenta=choice, cyan=plan, red=shell, yellow=edit.
  - Info rows in scrollback get a slate `▸ ` prefix so they read as
    structured "system note" lines instead of dim continuation text.

ModalCard is plain Text for the rules + a bg-pill title — no Ink
borderStyle, so it stays compatible with the live region's eraseLines
constraint. EditConfirm's diff slicing budget is unchanged
(MODAL_OVERHEAD_ROWS still accounts for ~3 rows of frame).
2026-04-25 21:27:33 -07:00
esengine e463fe7518 feat(ui): animated prompt bar, user-msg band, reasoning pill, pickers polished
Five more visual upgrades across the most-touched surfaces:

  - PromptInput left bar — each row's ▎ picks a color from the
    brand gradient with a tick offset, so the bar visually flows
    vertically in sync with the StatsPanel's wordmark + rules.
    When the prompt is disabled (busy turn), the gradient is
    suppressed and the bar reverts to dim gray so the screen
    calms down for the active spinner above it.
  - User message — wrapped in a cyan left-only border Box matching
    the assistant treatment, so both sides of the conversation
    have the Discord-style avatar bar. Continuation lines align
    under the band.
  - ReasoningBlock — was a flat dim italic line, now opens with a
    solid violet pill (` ⋯ thinking `) and the body in violet
    italic. Reads as its own surface instead of blending into the
    rest of the message.
  - SlashSuggestions selected row — solid cyan-bg highlight (the
    whole row, like an editor's autocomplete focus row) instead
    of just colored text.
  - AtMentionSuggestions selected row — same solid cyan-bg
    treatment, basename + dir on one line for a tighter VS Code
    Quick Open feel.
2026-04-25 21:21:13 -07:00
esengine 53ab16bab4 feat(ui): wordmark + gradient rules now flow — colors travel each tick
Tick is ~120 ms. Wordmark shifts the gradient one step every 4 ticks
idle / 2 ticks busy, so the teal→cyan→blue→purple→fuchsia sweep
visibly travels across REASONIX like a neon sign. Top/bottom
GradientRule bars share the flow logic (slower — every 6 ticks) so
the whole panel feels alive without being twitchy. Brand mark ◈ keeps
its existing bold-pulse so 'app is thinking' stays distinct from
'colors are flowing.'
2026-04-25 21:05:49 -07:00
esengine 4b2f2bf183 feat(ui): full-height role bands, code block cards, modeline divider
Big visual upgrades on the surfaces with the most pixels:

  - Assistant message — Box with left-only border in role color
    (COLOR.assistant green) running the FULL height of the message,
    Discord-style avatar bar. Reasoning, branch, plan-state, body
    Markdown, stats footer, repair note all sit inside the band.
  - Tool result for edit_file — same left-band treatment in
    COLOR.tool yellow so the diff reads as a continuation of the
    pill above.
  - Markdown code blocks — round-bordered card in cyan, with the
    language label as a solid-bg pill in the top-left and amber
    body text. Fenced code now feels like a code block, not a
    bare yellow-on-default text dump.
  - OngoingToolRow — solid amber pill on the tool name, gradient
    progress line, paddingX so the row sits in its own column zone.
  - StatusRow — violet accent + paddingX + middle-dot separator
    instead of a grey one-liner.
  - ModeStatusBar — wrapped in a top dotted rule so the modeline
    reads as its own zone, separate from the prompt above. Helix /
    vim modeline feel.

Border usage notes: the assistant + tool left-only borders live
inside `<Static>` (scrollback) so Ink's eraseLines miscount that
banned bordered Boxes from the LIVE region doesn't apply. The live
region (StatsPanel, OngoingToolRow, ModeStatusBar, PromptInput)
still uses Text-only rules and pills, so the Windows ghost-row
class of regression stays out.
2026-04-25 20:57:02 -07:00
esengine 9d775acb94 feat(ui): theme module + gradient bars, gradient turn separator, polished metrics
Centralised palette/glyphs in theme.ts so the TUI reads as one
designed surface instead of fifteen components inventing their own
color choices. Reuses the brand teal→cyan→blue→purple→fuchsia
gradient that the wordmark already establishes.

  - StatsPanel: top + bottom gradient rules frame the header band;
    progress bar redesigned with ▰/▱ glyphs and threshold colors
    (green/amber/rose) using truecolor hex; metric cells now lead
    with a dim icon (▣ ctx, ⌬ cache, ◴ cost, ◐ balance) and the
    inline layout always shows the bar.
  - TurnSeparator: gradient rule on both sides of the brand mark
    instead of a flat dim minus row, so turn boundaries read as
    designed accents.
  - WelcomeBanner: gradient top/bottom rules + brand-color left bar
    + accent-color hint tokens. Card now feels like a card.
  - theme.ts: GRADIENT array, semantic COLOR map, GLYPH set,
    `gradientCells(width, glyph)` helper that other components
    consume to render colored rules without duplicating the
    interpolation logic.
2026-04-25 20:50:53 -07:00
esengine 70bffed518 feat(ui): visual upgrade — pills, card-style welcome, status bar weight
Three high-frequency surfaces redesigned with solid-bg pills + card
framing so the TUI reads as a designed product instead of a wall of
glyphs:

  - WelcomeBanner — left-bar card frame; sectioned 'welcome / quick
    start / tip' rows; first thing on screen now has visual weight
    instead of looking like a one-liner list.
  - ModeStatusBar — solid-bg pills (AUTO / REVIEW / PLAN MODE) like
    a vim/helix modeline so the bottom bar reads at a glance.
  - Tool result rows — bg pill on the tool name (yellow ✓ / red ✗)
    so a session full of dispatches has visible status hierarchy.
  - Assistant header — model name now a solid-green pill next to the
    ◆ glyph, matching the visual language of the other pills.

No new abstractions, no border boxes (still avoiding Ink's Windows
eraseLines miscount). Just pills + bars + paddingX consistently
applied to the surfaces the user sees most.
2026-04-25 20:41:52 -07:00
esengine 77285a251c fix(ui): WelcomeBanner gated on real conversation, not historical.length
historical always has at least one info row at startup (session state
banner, edit-mode hint, restored plan, etc.), so historical.length
=== 0 never holds and the welcome card never showed. Switch the gate
to 'no user/assistant message yet' so the card greets the empty
conversation as intended.
2026-04-25 20:36:19 -07:00
esengine 7f7b48021e fix(lint): biome wants the long Text body wrapped 2026-04-25 20:33:04 -07:00
esengine 732593e4ed chore(release): 0.7.4 2026-04-25 20:29:46 -07:00
esengine d4662df023 fix(input): hoist KeystrokeProvider above App; restore Ctrl+C/J + Shift+Enter
The 49d4648 stdin-reader refactor moved the KeystrokeProvider INSIDE
App's render but kept App-body `useKeystroke` calls — `useContext`
runs at hook-call time, so the provider wasn't mounted yet and the
returned bus was always null. Every global hotkey (Ctrl+C exit, Esc
abort, Shift+Tab edit-mode cycle, @-mention picker nav, slash
suggestion nav) silently never subscribed; PromptInput worked because
it lives in the provider subtree.

Fixes:
  - chat.tsx wraps <App> in <KeystrokeProvider> from outside; remove
    the inner one in App.tsx.
  - stdin-reader splits \r vs \n: \r → Enter (return:true),
    \n → Ctrl+J ({ctrl:true, input:"j"}). Was conflating both as
    Enter, so Ctrl+J fired submit instead of newline.
  - stdin-reader recognises modifyOtherKeys + kitty Enter sequences
    (27;2;13~ / 13;2u and shift/ctrl variants) so terminals that
    enable those protocols can distinguish Shift+Enter / Ctrl+Enter.
    On Windows + ConPTY the bytes get stripped before reaching us, so
    Shift+Enter still falls back to Enter there — Ctrl+J remains the
    universal newline.
  - App.tsx writes \x1b[>4;2m at startup (paired disable on unmount)
    to opt into modifyOtherKeys for terminals that honor it.
  - code.tsx drops the SIGINT/SIGTERM `once` handler that ran
    jobs.shutdown without exiting (it overrode Node's default
    "exit on Ctrl+C" with a silent no-op). Job cleanup moves to the
    `exit` event so any termination path drains the registry.
  - App.tsx: SIGINT and \x03 byte both call process.exit(0). Using
    Ink's exit() left the singleton stdin-reader's data listener
    attached and the event loop alive, so the terminal would hang.

Hint text updated to lead with [Ctrl+J] since Shift+Enter is
unreliable on the most common Windows host. Two diagnostic probe
scripts added under scripts/ for future input regressions.

Visual structure tightened along the way: a left-bar (▎) anchor
running down PromptInput, a thin rule under the StatsPanel, the
existing TurnSeparator now actually rendered between user turns,
StatsPanel pills/wordmark restored from the in-flight working tree.
2026-04-25 20:29:38 -07:00
esengine 06f4716e17 feat(ui): welcome banner on the empty session
When the user opens reasonix and nothing has happened yet, the screen
showed StatsPanel + the bare prompt — no signpost for what to type.
Add a small Welcome card with three or four hand-picked hints
(/help, /skill, @path / !cmd in code mode, /exit). Disappears the
moment the first event lands so it never gets in the way during a
real session.

Tight on purpose. Full reference is /help; this is just the friction-
removing first impression.
2026-04-25 18:38:17 -07:00
esengine 49d4648b07 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.
2026-04-25 10:50:08 -07:00
esengine 634f8e6028 refactor(input): centralise CSI recovery in key-normalize boundary
Audit context. We've shipped three patches in two days for the same
class of bug:
  1. paste markers `[201~` leaking into the prompt buffer
  2. arrow keys not advancing the cursor across newlines on Windows
  3. (foreseeable) Shift+Tab / PgUp / PgDn / Delete next, since they
     all share the root cause.

Root cause is upstream: Ink's parse-keypress uses a 100ms timeout
to assemble CSI sequences from stdin. On Windows PowerShell + ConPTY
the bytes of `\x1b [ A` arrive split across reads with > 100ms
gaps. The standalone `\x1b` fires as `key.escape:true`, the trailing
`[A` fires as a regular character event with `input:"[A", upArrow:
undefined`. Every useInput consumer that reads structured key flags
silently misses the keystroke and the bare bytes get inserted into
the buffer as text.

Each individual patch was correct but localised — multiline-keys had
its own arrow recovery, PromptInput had its own paste-marker recovery,
App.tsx / Select / EditConfirm didn't recover anything at all and
would have been the next bugs found.

Fix is a single boundary. `src/cli/ui/key-normalize.ts` exposes:

  - `recoverCsiTail(input, existingFlags?)` — the canonical mapping
    from bare-or-prefixed CSI tails to structured Ink key flags.
    Covers `[A`/`[B`/`[C`/`[D` (arrows), `[5~`/`[6~` (PgUp/PgDn),
    `[3~` (Delete), `[Z` (Shift+Tab). Bracketed-paste markers
    intentionally excluded — they have collect-between-markers
    semantics and live in PromptInput's accumulator.

  - `stripCsiFragments(input)` — defensive scrub for the edge case
    where a fragment slips past structured-key recovery (e.g. arrived
    embedded inside a paste burst). Replaces the inline replaceAll
    chains we'd grown in multiline-keys.

Wired into every useInput call site that reads structured key flags:
multiline-keys (PromptInput's reducer), App's global hotkey handler
(Esc / Shift+Tab), Select's SingleSelect + MultiSelect (modal
navigation), EditConfirm's scroll/decision handler. DiffApp /
ReplayApp left for follow-up — separate subcommands, lower blast
radius.

+14 tests covering every supported tail, the pass-through path, the
already-structured-event short-circuit, and the literal `[A](url)`
non-rewrite. 1267 → 1283 tests.
2026-04-25 09:28:44 -07:00
esengine 9d09a14f91 fix(prompt): ESC-stripped arrow keys (Windows ConPTY) move the cursor
User reported pressing right-arrow at end of line 1 would not advance
the cursor to line 2. Same root cause as the recent [201~ paste-marker
fix: Windows PowerShell + ConPTY consumes the leading \x1b of the
arrow's CSI sequence and routes the remaining '[C' through useInput
as plain text. Our rewriteRawArrowEscape only matched the full
'\x1b[C' form, so the keystroke landed on the printable-char branch
and inserted literal '[C' into the buffer instead of moving the
cursor.

Add fallbacks for the bare '[A'/'[B'/'[C'/'[D' input strings. +1 test
covering the cross-newline rightArrow case directly.
2026-04-25 09:11:00 -07:00
esengine 1c023126d9 fix(prompt): handle ESC-less bracketed-paste markers
Windows PowerShell + ConPTY + Ink's parse-keypress consumes the
leading \x1b of \x1b[200~ / \x1b[201~ and routes the rest into
useInput as plain text. Our markers were the full sequence, so
indexOf missed them and the literal '[201~' ended up inserted into
the user's prompt buffer.

PromptInput now searches for both the full marker and the bare
'[200~'/'[201~' fallback. multiline-keys.ts strips both forms
defensively too. +1 test.
2026-04-25 09:06:21 -07:00
esengine 87d87c8f47 feat(ui): drop bordered Boxes throughout the live region
User reported repeating ghost top-borders accumulating in scrollback
on Windows + CJK input — every render leaked one row, so 17+ ghost
`╭──...─╮` lines stacked at the top within seconds. Root cause: Ink's
eraseLines counts logical rows, not post-wrap visual rows. Bordered
Boxes were both the most common wrap source AND the most visually
obvious thing to duplicate.

Fix: remove `borderStyle="round"` from every component in the live
region — StatsPanel, PromptInput, all *Confirm modals, UndoBanner,
EventLog plan/plan-resumed/plan-replay rows. Visual hierarchy now
comes from bold colored headers + indentation + marginY breathing.

Also pillify the active mode tag in ModeStatusBar — `▸ AUTO` becomes a
solid colored background block ` AUTO ` so the current mode reads at
a glance and the focus animation (briefly inserting a ◆) is more
distinct than `inverse` toggling.

No content lost; same affordances, less paint surface for Ink to get
wrong on fragile terminals.
2026-04-25 09:01:59 -07:00
esengine 50c757d203 fix(prompt): pin PromptInput Box to width=cols, fold Esc hint inside
User reported ghost top borders stacking up in scrollback after typing
two lines of CJK content, plus apparent cursor-stuck-on-line-1. Same
class of Ink eraseLines miscount that StatsPanel solved with explicit
width={cols} — long Chinese input (2 cells per char) overflows the
auto-flexed Box, every ticker tick leaves a ~1 row residual, and after
~3s the user sees 20+ ghost border tops.

Two changes:
- Pin the bordered Box to `width={cols}` so Ink's measurement matches
  reality (same fix StatsPanel applies).
- Fold the disabled-state [Esc] hint back inside the bordered Box. The
  `<>`-wrapped sibling Box from 0.7.2 was a structural variable with no
  layout benefit. Continuation-indented row inside the box matches the
  visual hierarchy without changing the live-region shape.

The cursor-stuck symptom was rendering corruption — once the live
region stops accumulating ghost rows, useInput delivery + setCursor
land where they should.
2026-04-25 08:34:19 -07:00
35 changed files with 3779 additions and 1328 deletions

No files matched your search

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