Compare commits

...
19 Commits
Author SHA1 Message Date
YHH 04b12e9f2d chore(release): 0.30.3 (#382)
Ink 7 + React 19 chat scroll rewrite (#380), configurable web_search
backend with SearXNG (#338), MCP filesystem sandbox preflight + wizard
mkdir confirm (#362, PR #379), README URL fix (#375), bug template
update (#378).
2026-05-07 06:29:05 -07:00
YHH b07b86ee2f feat(ui): row-precision virtual scroll on Ink 7 + React 19 (#380)
* feat(ui): row-precision virtual scroll on Ink 7 + React 19

Bump ink 5.2→7.0.2 + react 18.3→19.2; drop react-reconciler/yoga-layout.
Retire src/renderer/ (cell-diff + ink-compat shim) — Ink 7 ships native
equivalents for every motivation: alternateScreen, useAnimation,
useWindowSize, useBoxMetrics, synchronized output, incrementalRendering.

Default to alt-screen so the resize-ghost / scroll-yank class of bugs
disappears. `--no-alt-screen` opts back into scrollback for users who
need chat output preserved in shell history on exit.

Row-precision virtual scroll with reading mode: scrolling away from
bottom hides PromptInput and replaces it with a "📖 reading history —
End / PgDn to return" indicator. PgUp/PgDn/End owned by App; ↑/↓ on
an empty buffer hand off to chat scroll via PromptInput; App takes ↑/↓
directly when busy or in reading mode (PromptInput can't process them).
Wheel events translate to ↑/↓ in raw mode, so the same paths cover wheel.
Ctrl+P/N keeps the prompt-history binding (wheel-immune).

Drop <Static> — incompatible with alt-screen (no scrollback to shelter
committed cards across resize-reflow). React.memo on CardRenderer plus
the reducer's reference-stable card array skips reconciler cost on
unchanged cards. CardStream renders all cards inside an overflow:hidden
Box; inner Box's marginTop=-scrollRows shifts content; useBoxMetrics
reports inner/outer heights so App can clamp scroll bounds.

CardStream.isSettled covers task / plan / subagent kinds. PromptInput's
useReserveRows quantizes to 4-row buckets so per-keystroke layout doesn't
churn. TickerProvider migrated to Ink 7's useAnimation. Modal-open
condition collapsed from 14 OR'd checks into one reused boolean.

Migrate inline tests (markdown, plan-confirm) off the cell-diff renderer
to ink-testing-library. Remove dead alt-screen.ts; strip stale comments
referencing removed scrollback-mode workarounds.

* test(ui): provide fake stdin for Ink 7 raw-mode check in CI

Ink 7 calls setRawMode on stdin during render(); CI's process.stdin
isn't a TTY so it throws "Raw mode is not supported". Pass a minimal
EventEmitter-based fake stdin (setRawMode no-op, isTTY=true) alongside
the existing fake stdout. Affects four tests that import render directly
from ink for in-process render-and-assert: ui-session-picker-currency,
ui-stats-panel-currency, ui-usage-card-balance, ui-status-row-balance.

* test(ui): extract makeFakeStdin/Stdout to shared tests/helpers/ink-stdio
2026-05-07 06:14:37 -07:00
dacec354 7ba3d086ff chore(issue-template): add shell/terminal fields, update model examples (#378) 2026-05-07 05:33:20 -07:00
YHH 1a7d74f3f0 fix(mcp): preflight filesystem sandbox dir + offer mkdir in wizard (#362) (#379)
The official @modelcontextprotocol/server-filesystem refuses to start
when its sandbox directory is missing and prints a raw Node stack
trace, leaving the user without an actionable next step.

- preflightStdioSpec validates every positional path arg before
  spawn and throws a one-line error: "create it with: mkdir -p '<path>'"
- The first-run / reconfigure wizard now stat()s the directory the
  user typed for filesystem MCP and offers Y/Enter to mkdir -p,
  N/Esc to enter a different path.
2026-05-07 05:31:09 -07:00
dacec354 0f7dcd026a fix(readme): correct website URLs — /reasonix/ → /DeepSeek-Reasonix/ (#375) 2026-05-07 04:43:08 -07:00
dacec354 12998d246d feat(web): configurable web_search backend with SearXNG support (#338)
Adds SearXNG as an alternative web_search engine, switchable via /search-engine slash command or ~/.reasonix/config.json. Mojeek stays the default — zero risk for existing users.
2026-05-07 02:02:06 -07:00
YHH 43d437fd38 chore(release): 0.30.2 (#361) 2026-05-06 23:06:55 -07:00
YHH 62813e3d6d fix(dashboard): cost displays follow wallet currency, not hardcoded USD (#360)
CLI shows session cost in the wallet's currency (¥ for CNY wallets,
$ for USD), but the dashboard always used `fmtUsd()` which prints
the raw stored USD with a `$`. CNY users saw `$0.5190` in the
dashboard for the same session that read `¥0.024` in the CLI — both
the symbol AND the magnitude diverged because no conversion happened.

- New `fmtCost(usd, currency, fractionDigits?)` helper in
  `dashboard/src/lib/format.ts`. Mirrors the CLI's `formatCost()` —
  CNY converts via `USD_TO_CNY = 7.2`, USD passes through, undefined
  defaults to CNY.
- `overview.ts` current-session cost + cost-trend day average now
  thread `c.balance?.currency` into `fmtCost()`.
- `chat.ts` SideRail cost / budget rows and ChatStatusBar
  turn / session cost rows pull currency from `stats.balance[0]`.
- Existing `fmtUsd()` kept as-is for places where USD is intentional
  (Claude-equivalent comparisons in usage.ts) — those stay USD because
  Claude's API is USD-priced regardless of the user's wallet.
2026-05-06 23:00:56 -07:00
YHH a29a0ac157 feat(ui): ctrl-o expands the live streaming card to full tail (#335, #337) (#359)
Pain point @dacec354 raised twice: while the model writes a long plan
or todo list, the streaming card only shows the last 4 lines, so the
content scrolls past before you can read it. And once a long reply
settles, you may want to fold it back down — but `<Static>` already
flushed the bytes to scrollback so that half can't be unwound.

This collapses both requests into one keystroke: `ctrl-o` toggles
"expanded" mode on the live streaming card. Expanded shows the last
60 visual lines (capped so the card can't swallow the whole viewport)
plus a `⋯ N earlier lines above` hint when content overflows.
Collapses back to the 4-line preview. Auto-resets to collapsed at the
end of every turn so each new reply starts compact.

The same `(LIVE)` constraint covers #337's fold-toggle ask: only
not-yet-committed cards are addressable. Settled cards live in Ink's
`<Static>` which writes terminal scrollback once, no take-backs.

The `expanded ⌃o` / `preview ⌃o` pill in the card header advertises
the keybind so users discover it without reading docs.
2026-05-06 22:48:35 -07:00
YHH ccdbfb3a28 fix(ui): reasoning spinner survived reasoning.end when a later card existed (#358)
`splitCardStream` only treated the LAST card as live; everything before
it was committed to Ink's `<Static>`. When the model streamed reasoning
THEN content (or kicked off a tool card), the reasoning card was
no longer last — so it got frozen into `<Static>` while still
`streaming: true`. `reasoning.end` later set `streaming: false` on
the cards array, but `<Static>` doesn't re-render frozen items, so the
"reasoning…" header kept its spinner indefinitely.

Fix: live-vs-committed split now scans for the FIRST unsettled card
and keeps everything from there onward in the live tree. Cards only
move into `<Static>` once they're settled AND every earlier card is
too — preserving the in-order append contract `<Static>` requires.

`suppressLive` semantic preserved: still hides the trailing live card
when the modal owns the screen.

Two new regression tests cover the reasoning + streaming case (must
stay live) and the all-settled case (must commit in order).
2026-05-06 22:33:06 -07:00
YHH 1805b0c647 fix(ui): SessionPicker honors wallet currency instead of hardcoded ¥ (#312) (#357)
Same bug shape as the StatusRow / UsageCard / StatsPanel set that #272
already fixed — `SessionPicker` was still doing
`¥${totalCostUsd * USD_TO_CNY}` so USD-wallet users saw ¥-formatted
costs in the session list.

- `SessionMeta` gains `balanceCurrency?: string`. App.tsx writes the
  live wallet currency alongside `totalCostUsd` on each turn save, so
  every persisted session knows what currency its cost is denominated
  in.
- `SessionPicker` accepts a `walletCurrency` prop and falls back to
  each row's `meta.balanceCurrency`. Prop wins (live value > stored).
  In-app picker (`/sessions`) gets the live ref from App; pre-mount
  picker in `chat.tsx` doesn't have a balance fetched yet, so it
  relies on the stored per-session value.
- Cost rendering routes through the shared `formatCost()` helper that
  the rest of the cost UI already uses.
- Regression test asserts USD wallet → `$0.05`, CNY wallet → `¥0.36`,
  no-prop + stored-USD → `$0.05`, neither → unchanged `¥0.36`.
2026-05-06 22:22:11 -07:00
YHH 4032930fed feat(ui): live tokens/s on streaming reply + space pauses 5s undo (#334, #337) (#356)
- StreamingCard renders a `42 t/s` pill in the live header and a
  `1.2k tok · 42 t/s` summary on the settled card. Computed via the
  bundled DeepSeek tokenizer; gated below 4 tokens / 500 ms so the
  pill never shows obviously-bogus rates while the first chunk lands.
  Re-renders piggyback on the slow tick so the rate keeps updating
  even when chunks stall briefly.
- The auto-mode undo banner now accepts a `space` keystroke to pause
  the 5-second countdown indefinitely. While paused the bar freezes
  at the captured fraction, the badge swaps to "Ns · paused", and
  pressing space again resumes from where it stopped. The hint row
  on edit-mode entry mentions both `u` and `space`.

The `u` and `space` keybinds share the same modal-and-prompt-empty
gating, so neither steals input while the user is typing.
2026-05-06 22:08:19 -07:00
YHH b03e11bf41 chore(release): 0.30.1 (#355) 2026-05-06 21:15:07 -07:00
ctharveyandCharles H 0b91250c44 fix(ui): suppress live card redraw while picker modal is open (#353)
Co-authored-by: Charles H <[email protected]>
2026-05-06 21:09:55 -07:00
YHH 5bcc3b6389 fix(renderer): drop cell-diff aliasing, run prod on real Ink (#346) (#354)
Aliasing `from "ink"` → ink-compat in the CLI bundle made every
TUI bug invisible to `npx tsx` repros and forced cell-diff regressions
onto users only. Issue #346 (ShellConfirm ghost rendering) was a pure
cell-diff bug; the cleanest fix is to remove the alias and let
production take real Ink the same way source mode does.

- tsup: drop the `ink` alias and `noExternal` for ink/ink-text-input;
  bundle keeps `from "ink"` external
- package.json: ink + ink-text-input move to runtime dependencies
- markdown-view: switch the only `inkCompat.*` namespace user back to
  `import { Box, Text } from "ink"`
- bundle-smoke: invert the alias assertion to expect ink stays external

useCursor() in PromptInput keeps its cell-diff import; without a
CursorContext provider it no-ops, so it's harmless under real Ink.
2026-05-06 21:01:59 -07:00
YHH 0f7f7c24c2 docs(benchmarks): real-world cache-hit case study (#351)
Anonymized DeepSeek dashboard from a real Reasonix user
(2026-05-01, used with permission): 435M input tokens, 99.82%
cache hit, ~$12/day on v4-flash vs ~$61 the same workload would
cost without the cache.

Lives at benchmarks/real-world-cache/ alongside an explanation of
the four mechanisms (ImmutablePrefix + AppendOnlyLog +
VolatileScratch + auto-compact) that produce the hit rate. README
gets a NOTE callout pointing at it — answers the recurring "isn't
this just DeepSeek's prefix cache?" pushback with concrete numbers
instead of a re-derived napkin.
2026-05-06 18:54:57 -07:00
YHH 6379bc0628 chore(release): 0.30.0 (#349) 2026-05-06 18:06:28 -07:00
YHH 82c5627123 fix(renderer): use CHA absolute for column moves in cell-diff (#346) (#348)
Switch the X-axis branch of `moveTo()` from CUF (relative cursor right,
`\x1b[NC`) to CHA (absolute column, `\x1b[N+1G`). Y-axis stays on
CUU/CUD since we don't track absolute terminal rows.

Relative column moves accumulate drift across frames: if any earlier
write miscounted cell width — wide-char disagreement (`▸` U+25B8 in
"narrow" fonts that render 2-cell), `▎` ambiguous-width on terminals
that font-detect East Asian width, OSC8 hyperlinks parsed as visible
chars, etc. — the cursor's actual column desyncs from what the
renderer thinks. The next CUF lands at the wrong column, ghost-rows
get written into hint lines, and the modal "shifts" as users navigate
(symptom in #346 issue screenshots).

Anthropic's Claude Code TUI hit the same class of bug
(anthropics/claude-code#14208) and the fix was the same: prefer CHA
over CUF/CUB so column targeting is immune to per-cell width
miscounts. Fix is invisible on terminals where our width math was
already correct; on terminals where it wasn't, no more accumulating
drift.
2026-05-06 18:02:08 -07:00
YHH e991644416 feat(slash): first-class command aliases (#332) (#347)
Add `aliases?: readonly string[]` to SlashCommandSpec so /quit + /q
resolve to /exit, /? to /help, /reset to /new, /lang to /language —
all from a single declaration on the spec instead of ad-hoc handler
mirrors scattered across handlers/basic.ts + handlers/language.ts.

dispatch, suggestSlashCommands and detectSlashArgContext all route
through one resolveSlashAlias() built from SLASH_COMMANDS at module
init. Suggestion rows surface aliases dimly (` · /quit /q`) so they
stay discoverable without polluting the autocomplete list with
duplicates. Dashboard /api/slash response now exposes the field too.
2026-05-06 18:01:54 -07:00
147 changed files with 1877 additions and 9126 deletions

No files matched your search

+3 -1
View File
@@ -17,7 +17,9 @@ Steps or minimal code that reproduces it.
- Reasonix version:
- Node version:
- OS:
- DeepSeek model (e.g. `deepseek-chat`, `deepseek-reasoner`):
- Shell (bash, zsh, fish, PowerShell, etc.):
- Terminal emulator (kitty, WezTerm, Windows Terminal, etc.):
- DeepSeek model (e.g. `deepseek-v4-flash`, `deepseek-v4-pro`):
**Logs / transcript**
If using the CLI, attach the relevant chunk of `--transcript`.
+2
View File
@@ -33,4 +33,6 @@ sessions/
.reasonix/semantic-skip
# Scratch entry regenerated each time scripts/bundle-codemirror.mjs runs.
scripts/.cm-entry.mjs
# Personal bun lockfile — project uses npm officially.
bun.lock
+192
View File
@@ -3,6 +3,198 @@
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.30.3] — 2026-05-07
**Headline:** the chat scroll rewrite lands. Ink 5.2 → 7.0.2 / React
18.3 → 19.2, the cell-diff renderer is retired, and `reasonix code` /
`reasonix chat` default to alt-screen with row-precision virtual
scroll. PgUp / PgDn / mouse wheel scroll history; an empty prompt + ↑
also scrolls (Ctrl+P / Ctrl+N still recalls prompt history). When
scrolled away from bottom, the prompt hides and a `📖 reading
history — End / PgDn to return` hint appears. Resize-ghost dividers
and `<Static>`-related scroll-yank artifacts are gone with the
renderer that produced them. `--no-alt-screen` keeps the legacy
in-shell-scrollback behavior.
`web_search` gains a configurable backend — Mojeek stays the default,
but `/web-search-engine searxng <url>` switches to a self-hosted
SearXNG instance for users whose network blocks Mojeek. And the MCP
filesystem sandbox now fails with an actionable
`mkdir -p '<path>'` hint instead of a raw Node stack when the
configured directory doesn't exist; the wizard offers to create it
inline at config time.
**Features:**
- feat(ui): row-precision virtual scroll on Ink 7 + React 19.
`<Static>` retired (incompatible with alt-screen reflow);
`React.memo(CardRenderer)` plus reference-stable cards in the
reducer skip the reconciler on unchanged history. `useChatScroll`
drives an outer `overflow=hidden` clip + inner `marginTop=-N`
slide; `useBoxMetrics` reports inner / outer heights so bounds
clamp and auto-pin to bottom on new content. `App` owns
PgUp/PgDn/End/wheel; PromptInput hands off ↑/↓ on empty buffer
when pinned + idle. Ticker migrated to Ink 7's shared
`useAnimation`. (PR #380)
- feat(web): configurable `web_search` backend with SearXNG support.
`/web-search-engine` shows / switches the active engine; URL is
persisted to `~/.reasonix/config.json`. Mojeek remains the default;
the original Mojeek path is preserved as `searchMojeek()`. Protocol
auto-normalizes (`localhost:8080` → `http://...`); an unreachable
SearXNG endpoint surfaces an install hint instead of a raw fetch
error. (PR #338)
**Bug fixes:**
- fix(mcp): preflight the filesystem sandbox directory before
spawning `@modelcontextprotocol/server-filesystem`. Missing
directories now throw `MCP filesystem sandbox '<path>' does not
exist — create it with: mkdir -p '<path>'` instead of a raw Node
stack from inside `npx`'s child. The init wizard adds an inline
`[Y] create it (mkdir -p) / [N] enter a different path` confirm
step when the user types a path that doesn't exist, so bad config
never reaches disk. Spawn-time path deliberately does not
auto-mkdir — by then the user may not remember writing the
config. (#362, PR #379)
- fix(readme): website URLs corrected from `/reasonix/` to
`/DeepSeek-Reasonix/`. (PR #375)
**Chores:**
- chore(issue-template): bug template now asks for shell + terminal,
and the model-id examples track the current DeepSeek model
lineup. (PR #378)
## [0.30.2] — 2026-05-07
**Headline:** five user-visible polish items from the @dacec354 triage
batch. The streaming reply now carries a live `42 t/s` throughput pill
(plus a `1.2k tok · 42 t/s` summary on settled), and `ctrl-o` toggles a
full-tail view so a long plan / todo can be read while it's still
being written. The auto-mode undo banner gains a `space`-to-pause
keybind for users who want a beat to think before the 5-second window
expires. SessionPicker and the dashboard's session-cost displays both
respect the user's wallet currency now — USD wallets see `$0.05`, CNY
wallets see `¥0.36` end-to-end. And a long-standing scrollback bug
that left the "reasoning…" spinner spinning forever after reasoning
ended is fixed.
**Features:**
- feat(ui): live `42 t/s` pill on the streaming reply card; settled
card shows `1.2k tok · 42 t/s` summary. Computed via the bundled
DeepSeek tokenizer; gated below 4 tokens / 500 ms so the first
chunk doesn't print bogus rates. Re-renders ride the slow tick so
the rate keeps updating during chunk silence. (#334, PR #356)
- feat(ui): `space` toggles pause / resume on the auto-mode 5-second
undo countdown. While paused the bar freezes at the captured
fraction, the badge swaps to `Ns · paused`, and pressing `space`
again resumes from where it stopped. The `u` and `space` keybinds
share the same modal-and-prompt-empty gating. (#337, PR #356)
- feat(ui): `ctrl-o` toggles "expanded" mode on the live streaming
card. Expanded shows up to 60 visual lines (capped so the card
can't swallow the whole viewport) plus a `⋯ N earlier lines above`
hint when content overflows. Auto-resets to collapsed at turn end.
A `expanded ⌃o` / `preview ⌃o` pill in the card header advertises
the keybind. (#335, #337, PR #359)
**Bug fixes:**
- fix(ui): `splitCardStream` only treated the LAST card as live,
committing every earlier card to Ink's `<Static>`. When the model
streamed reasoning then content (or kicked off a tool card), the
reasoning card was no longer last — it got frozen into `<Static>`
while still `streaming: true`. `<Static>` doesn't re-render frozen
items, so when `reasoning.end` later set `streaming: false`, the
spinner kept spinning forever. The split now scans for the first
unsettled card and keeps everything from that index onward live;
a card only commits to `<Static>` once it's settled AND every
earlier card is too. (PR #358)
- fix(ui): SessionPicker hardcoded `¥` and ran USD → CNY itself, so
USD-wallet users saw `¥X.XX` in the session list. `SessionMeta`
gains `balanceCurrency`; App.tsx writes the live wallet currency
alongside `totalCostUsd` on each turn save. Picker accepts a
`walletCurrency` prop and falls back to each row's stored
currency. Cost rendering routes through the shared `formatCost()`
helper. (#312, PR #357)
- fix(dashboard): cost displays were hardcoded to `$` via `fmtUsd()`,
so a CNY-wallet user saw `session $0.5190` in the dashboard while
the same session read `¥0.024` in the CLI — both the symbol AND
the magnitude diverged because no conversion happened. Dashboard
now has its own `fmtCost(usd, currency)` mirroring the CLI's
conversion (CNY × 7.2). Overview current-session cost, cost-trend
day average, and the chat panel rail / status-bar costs all
thread the wallet currency from the cockpit balance. Claude-
equivalent comparisons in `usage.ts` stay USD by design — Claude's
API is USD-priced regardless of the user's wallet. (PR #360)
## [0.30.1] — 2026-05-07
**Headline:** two TUI ghost-rendering fixes for issues that only showed
up on the published binary. The CLI bundle now uses real Ink in
production instead of the cell-diff renderer that source mode never
exercised, eliminating a whole class of bugs invisible to `npx tsx`
repros. The `submit_plan` approval picker no longer leaves a
duplicated row behind when arrow-navigating choices — the live tool
card above the modal is suppressed while the picker owns the screen.
**Bug fixes:**
- fix(renderer): drop the `tsup` `ink → ink-compat` alias and the
`noExternal` for `ink` / `ink-text-input`. The CLI bundle keeps
`from "ink"` external; `ink` and `ink-text-input` move to runtime
`dependencies` so npm install pulls the real package. The
cell-diff renderer is no longer on the user-facing path; it's
retained only for direct test imports. Same behavior as `npx tsx
src/cli/index.ts` mode — TUI bug repros from source mode are now
valid for the published binary again. (#346, PR #354)
- fix(ui): `CardStream` accepts a `suppressLive` flag; `App.tsx`
computes a `modalOpen` flag from the union of pending modal states
and passes it through. While any picker / confirm modal owns the
screen, the unsettled live tool card above it stops repainting,
removing the rerender competition that left stale rows during
arrow-key navigation. (#352, PR #353 — thanks @ctharvey)
## [0.30.0] — 2026-05-06
**Headline:** slash commands grow first-class aliases, and the
cell-diff renderer hardens column targeting against per-cell width
miscounts. `/quit` and `/q` now resolve to `/exit` from a single
declaration on the spec instead of ad-hoc handler mirrors; `/?` →
`/help`, `/reset` → `/new`, `/lang` → `/language` follow the same
path. The renderer's `moveTo()` now uses CHA absolute (`\x1b[N+1G`)
for column targeting instead of CUF relative (`\x1b[NC`), making the
diff stream immune to the cursor-drift class of bug Anthropic
documented in `claude-code#14208`.
**Features:**
- feat(slash): `aliases?: readonly string[]` on `SlashCommandSpec`.
Adding a new alias is now a one-line edit to the canonical command
— dispatch, autocomplete, arg-context resolution, and the
dashboard `/api/slash` response all route through one
`resolveSlashAlias()` map built from `SLASH_COMMANDS` at module
init. Suggestion rows display aliases dimly (` · /quit /q`) so
they stay discoverable without doubling the autocomplete list.
Removes the per-handler alias mirrors that used to live in
`handlers/basic.ts` and `handlers/language.ts`. (#332, PR #347)
**Bug fixes:**
- fix(renderer): switch the X-axis branch of `moveTo()` from CUF
relative (`\x1b[NC`) to CHA absolute (`\x1b[N+1G`). Y-axis stays
on CUU/CUD since we don't track absolute terminal rows. Relative
column moves accumulate drift across frames whenever an earlier
write miscounts cell width — `▸` (U+25B8) rendered 2-cell on
fonts with East Asian fallback, ambiguous-width chars on
terminals that font-detect width, OSC8 hyperlinks parsed as
visible chars, etc. The next CUF lands at the wrong column,
ghost rows leak into adjacent hint lines, and the modal "shifts"
as users navigate. CHA targets the absolute column regardless of
what the terminal thinks — immune to the desync chain. Same fix
Anthropic shipped in claude-code per their issue #14208
post-mortem. (#346, PR #348)
## [0.29.1] — 2026-05-06
**Headline:** four user-reported bugs from the 0.29.0 release window.
+51 -3
View File
@@ -7,7 +7,7 @@
&nbsp;·&nbsp;
<a href="./README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/reasonix/">Website</a>
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<a href="./docs/ARCHITECTURE.md">Architecture</a>
&nbsp;·&nbsp;
@@ -41,6 +41,52 @@
> [!TIP]
> **Cache stability isn't a feature you turn on; it's an invariant the loop is designed around.** That's the whole reason Reasonix is DeepSeek-only — every layer is tuned to the byte-stable prefix-cache mechanic.
> [!NOTE]
> **Real user, single day (2026-05-01):** 435M input tokens, **99.82% cache hit**, ~$12 instead of the ~$61 the same workload would cost with no cache on `v4-flash` — see the [case study](./benchmarks/real-world-cache/README.md). DeepSeek provides the cacheable bytes; the four mechanisms in [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) are how Reasonix keeps them cacheable across long sessions.
<br/>
## Web search
Reasonix includes `web_search` and `web_fetch` tools. By default it uses **Mojeek** (no setup required). You can switch to a **self-hosted SearXNG** instance — a metasearch engine that aggregates whatever upstream engines your instance is configured for.
### Switching engines (persists to disk)
The `/search-engine` slash command (alias `/se`) writes your choice to `~/.reasonix/config.json` immediately — it survives restarts:
```
/search-engine mojeek # default, no external deps
/search-engine searxng # SearXNG at http://localhost:8080
/search-engine searxng http://192.168.1.100:8888 # custom endpoint
```
Equivalent `~/.reasonix/config.json`:
```json
{
"webSearchEngine": "searxng",
"webSearchEndpoint": "http://localhost:8080"
}
```
The tool picks up the change on the next call — no restart needed.
### Starting SearXNG
```sh
podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng
# or: docker run -d -p 8080:8080 searxng/searxng
```
Verify it's running:
```sh
curl http://localhost:8080/search?q=test
# → HTML search results page
```
> **Note:** The endpoint must include the protocol (`http://`). `localhost:8080` alone will fail — the tool will show a clear error telling you to install SearXNG if the server is unreachable.
<br/>
## Install
@@ -87,8 +133,10 @@ Click any card to read the full architecture writeup → [Pillar 1](./docs/ARCHI
| Cost profile | **low per task** | premium | subscription + use | varies |
| DeepSeek prefix-cache | **engineered** | not applicable | not applicable | incidental |
| Embedded web dashboard | yes | — | n/a (IDE) | — |
| Configurable web search engine | `/search-engine` | — | — | — |
| Persistent per-workspace sessions | yes | partial | n/a | — |
| Plan mode · MCP · hooks · skills | yes | yes | yes | partial |
| Web search (Mojeek + SearXNG) | yes | yes | yes | yes |
| Open community development | yes | — | — | yes |
For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmarks/) — the numbers move with model pricing, so they live with the harness, not in the README.
@@ -99,7 +147,7 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
- [**Architecture**](./docs/ARCHITECTURE.md) — the four pillars, cache-first loop, harvesting, scaffolds
- [**Benchmarks**](./benchmarks/) — τ-bench-lite harness, transcripts, cost methodology
- [**Website**](https://esengine.github.io/reasonix/) — getting started, dashboard mockup, TUI mockup
- [**Website**](https://esengine.github.io/DeepSeek-Reasonix/) — getting started, dashboard mockup, TUI mockup
- [**Contributing**](./CONTRIBUTING.md) — comment policy, error-handling rules, library-over-hand-rolled
- [**Code of Conduct**](./CODE_OF_CONDUCT.md) · [**Security policy**](./SECURITY.md)
@@ -115,7 +163,7 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
**Open Discussions — opinions wanted:**
- [#20 · CLI / TUI design](https://github.com/esengine/reasonix/discussions/20) — what's broken, what's missing, what would you change?
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/reasonix/design/agent-dashboard.html)
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)
- [#22 · Future feature wishlist](https://github.com/esengine/reasonix/discussions/22) — what would you build into Reasonix next?
**Already using Reasonix and willing to help others discover it?** Publish blog posts, articles, screenshots, talks, or videos to [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell). The project has no marketing budget — community word of mouth is how new users find it. Sustained advocates earn the badge below, displayed next to the contributors wall once awarded:
+3 -3
View File
@@ -7,7 +7,7 @@
&nbsp;·&nbsp;
<strong>简体中文</strong>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/reasonix/">官方网站</a>
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<a href="./docs/ARCHITECTURE.md">架构文档</a>
&nbsp;·&nbsp;
@@ -99,7 +99,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
- [**架构**](./docs/ARCHITECTURE.md) —— 四大支柱、缓存优先循环、思维提取、脚手架
- [**基准测试**](./benchmarks/) —— τ-bench-lite harness、transcript、成本方法论
- [**官方网站**](https://esengine.github.io/reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
- [**官方网站**](https://esengine.github.io/DeepSeek-Reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
- [**贡献指南**](./CONTRIBUTING.md) —— 注释规则、错误处理、用现成库不手写
- [**行为准则**](./CODE_OF_CONDUCT.md) · [**安全策略**](./SECURITY.md)
@@ -115,7 +115,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
**正在征集意见的 Discussions:**
- [#20 · CLI / TUI 设计](https://github.com/esengine/reasonix/discussions/20) —— 哪里坏了、哪里少东西、哪里你会怎么改?
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/reasonix/design/agent-dashboard.html)拍砖
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)拍砖
- [#22 · 未来功能愿望单](https://github.com/esengine/reasonix/discussions/22) —— 你希望 Reasonix 长出什么功能?
**正在使用 Reasonix,愿意让更多人了解它?** 欢迎将相关博客、文章、截图、演讲或视频发布到 [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell)。项目没有营销预算,新用户主要通过社区口碑找到这里。持续参与传播的用户将获得下方这枚徽章,颁发后会展示在贡献者墙旁:
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+76
View File
@@ -0,0 +1,76 @@
# Real-world cache hit — single user, single day
A real Reasonix user shared their DeepSeek dashboard for **2026-05-01**.
Used with permission, anonymized.
![DeepSeek usage dashboard, 2026-05-01](2026-05-01-deepseek-dashboard.png)
## The numbers
| | Tokens |
|---|---:|
| Input — cache hit | 435,033,856 |
| Input — cache miss | 767,616 |
| Output | 179,763 |
| **Day total** | **435,981,235** |
**Cache hit ratio (input):**
`435,033,856 / (435,033,856 + 767,616)` = **99.82%**
## Cost — using the prices Reasonix bills against (`src/telemetry/stats.ts`)
USD per 1M tokens — `inputCacheHit / inputCacheMiss / output`:
- `deepseek-v4-flash` — `0.028 / 0.139 / 0.278`
- `deepseek-v4-pro` — `0.139 / 1.667 / 3.333`
Assuming **v4-flash** (the project default):
| | This user (99.82% hit) | Same workload, **0% cache** |
|---|---:|---:|
| Cache-hit input | $12.18 | — |
| Cache-miss input | $0.11 | $60.58 |
| Output | $0.05 | $0.05 |
| **Total / day** | **$12.34** | **$60.63** |
→ Cache saved this user **$48.29**, or **~80%** off the un-cached baseline, on a single day.
On **v4-pro** (5× the prefix-cache discount) the same workload would cost
**~$62.35** vs **~$727.08** without cache — a **~91% saving**.
## "Isn't that just DeepSeek's prefix cache?"
DeepSeek's API ships prefix caching enabled by default; the *cache* is theirs,
the *hit rate* is the client's. Same API, different clients, very different
hit rates:
- DeepSeek's own web chat: 60–80% within a single conversation, drops to 0%
on a new session (system prompt may differ).
- Cherry Studio / Open WebUI / generic OpenAI-shape SDKs: typically 30–60%
on long sessions — history gets reordered, tool specs get re-serialized,
every drift breaks the prefix.
- Cline / Continue and other XML-tool-call clients: lower still — every tool
result inlines into the conversation, shifting bytes the cache keys on.
99.82% is what falls out of these four design choices in Reasonix:
1. **`ImmutablePrefix`** (`src/memory.ts`) — system prompt + tool specs are
frozen at session start. Same byte sequence every turn.
2. **`AppendOnlyLog`** — turns only append. No reorder, no edit-in-place.
3. **`VolatileScratch`** — chain-of-thought / per-turn scratch lives outside
the cached prefix so it never poisons the next hit.
4. **Auto-compact** — when context approaches the cap, older turns fold into
a summary message *appended* to the prefix; the prefix itself isn't
rewritten, so the cache survives the fold.
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
the bytes cacheable.
## Reproduce
The synthetic side of this lives in `benchmarks/tau-bench/` — same task set
run through `CacheFirstLoop` vs a deliberately cache-hostile baseline. The
real-world data above is what the synthetic numbers look like once a user
runs the harness in anger.
Submit your own dashboard screenshot if you want it anonymized and added
here — open an issue.
+18
View File
@@ -4,6 +4,24 @@ export function fmtUsd(n: number | null | undefined): string {
return `$${n.toFixed(n < 0.01 ? 6 : 4)}`;
}
/** Keep in sync with src/cli/ui/theme/tokens.ts USD_TO_CNY. */
const USD_TO_CNY = 7.2;
/** USD-internal cost rendered in the wallet's display currency. Undefined currency → CNY (matches CLI default). */
export function fmtCost(
usd: number | null | undefined,
currency: string | null | undefined,
fractionDigits?: number,
): string {
if (usd === null || usd === undefined) return "—";
const cur = currency ?? "CNY";
const amount = cur === "CNY" ? usd * USD_TO_CNY : usd;
if (amount === 0) return cur === "CNY" ? "¥0" : "$0";
const sym = cur === "CNY" ? "¥" : cur === "USD" ? "$" : `${cur} `;
const digits = fractionDigits ?? (Math.abs(amount) < 0.01 ? 6 : 4);
return `${sym}${amount.toFixed(digits)}`;
}
export function fmtPct(n: number | null | undefined): string {
if (n === null || n === undefined) return "—";
return `${(n * 100).toFixed(1)}%`;
+6 -5
View File
@@ -14,7 +14,7 @@ import {
} from "../components/chat-internals.js";
import { MODE, TOKEN, api } from "../lib/api.js";
import { appBus, showToast } from "../lib/bus.js";
import { fmtUsd } from "../lib/format.js";
import { fmtCost, fmtUsd } from "../lib/format.js";
import { html } from "../lib/html.js";
import { t, useLang } from "../i18n/index.js";
@@ -791,6 +791,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
const showBudget = stats != null && typeof budgetUsd === "number" && budgetUsd > 0;
const budgetPct = showBudget ? Math.min(120, (stats.totalCostUsd / budgetUsd) * 100) : 0;
const budgetTone = budgetPct >= 100 ? "err" : budgetPct >= 80 ? "warn" : "";
const walletCurrency = stats?.balance?.[0]?.currency;
return html`
<aside class="chat-rail">
${activePlan ? html`<${ActivePlanCard} plan=${activePlan} />` : null}
@@ -801,7 +802,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
<div class="rh">${t("chat.railSession")}</div>
<div class="rail-kv"><span class="k">${t("chat.railTurns")}</span><span class="v">${stats.turns.toLocaleString()}</span></div>
<div class="rail-kv"><span class="k">${t("chat.railPromptTok")}</span><span class="v">${stats.lastPromptTokens.toLocaleString()}</span></div>
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtUsd(stats.totalCostUsd)}</span></div>
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtCost(stats.totalCostUsd, walletCurrency)}</span></div>
<div class="progress-row" style="margin-top:8px">
<span class="lbl">${t("chat.railCacheHit")}</span>
<div class=${`progress ${cacheTone}`}><div class="progress-fill" style=${`width:${cachePct}%`}></div></div>
@@ -819,7 +820,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
<div class="progress-row">
<span class="lbl">${t("chat.railSpend")}</span>
<div class=${`progress ${budgetTone}`}><div class="progress-fill" style=${`width:${Math.min(100, budgetPct)}%`}></div></div>
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtUsd(stats.totalCostUsd)} / ${fmtUsd(budgetUsd)}</span>
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtCost(stats.totalCostUsd, walletCurrency)} / ${fmtCost(budgetUsd, walletCurrency)}</span>
</div>
</div>
`
@@ -989,11 +990,11 @@ function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
</span>
<span class="status-item">
<span class="status-label">${t("chat.statusTurn")}</span>
<code>${fmtUsd(stats.lastTurnCostUsd)}</code>
<code>${fmtCost(stats.lastTurnCostUsd, balance?.currency)}</code>
</span>
<span class="status-item">
<span class="status-label">${t("chat.statusSession")}</span>
<code>${fmtUsd(stats.totalCostUsd)}</code>
<code>${fmtCost(stats.totalCostUsd, balance?.currency)}</code>
<span class="muted" style="font-size: 10px;">
${t("chat.statusTurns", { count: stats.turns, s: stats.turns === 1 ? "" : "s" })}
</span>
+4 -3
View File
@@ -1,4 +1,4 @@
import { fmtCompactNum, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
import { fmtCompactNum, fmtCost, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
import { html } from "../lib/html.js";
import { usePoll } from "../lib/use-poll.js";
import { t, useLang } from "../i18n/index.js";
@@ -144,6 +144,7 @@ function currentSessionBlock(c: CockpitData) {
`;
}
const s = c.currentSession;
const currency = c.balance?.currency;
return html`
<div class="cock-list cock-w-2">
<div class="ch"><span class="ttl">${t("overview.currentSession")}</span></div>
@@ -153,7 +154,7 @@ function currentSessionBlock(c: CockpitData) {
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:8px;font-family:var(--font-mono);font-size:11px">
<div><span style="color:var(--fg-3)">${t("overview.promptTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.lastPromptTokens)}</div></div>
<div><span style="color:var(--fg-3)">${t("overview.completionTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.completionTokens)}</div></div>
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtUsd(s.totalCostUsd)}</div></div>
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtCost(s.totalCostUsd, currency)}</div></div>
</div>
</div>
`;
@@ -185,7 +186,7 @@ function costTrendSpark(c: CockpitData) {
return html`
<div class="chart cock-w-2">
<div class="chart-h"><span class="title">${t("overview.costTrend")}</span></div>
<div class="chart-v">${fmtUsd(avg)}<span class="unit">${t("overview.dayAvg")}</span></div>
<div class="chart-v">${fmtCost(avg, c.balance?.currency)}<span class="unit">${t("overview.dayAvg")}</span></div>
<div class="chart-spark">
<svg viewBox=${`0 0 ${w} ${h}`} preserveAspectRatio="none">
<polyline fill="none" stroke="var(--c-brand)" stroke-width="1.5" points=${points} />
+1 -1
View File
@@ -195,7 +195,7 @@ src/
│ ├── skills.ts # list + invoke SKILL.md playbooks
│ ├── subagent.ts # spawn_subagent — flash+high by default
│ ├── plan.ts # submit_plan (review gate)
│ └── web.ts # web_search, web_fetch
│ └── web.ts # web_search, web_fetch (multi-engine: Mojeek or SearXNG)
├── mcp/ # MCP client + bridge (stdio + SSE)
├── memory.ts # ImmutablePrefix / AppendOnlyLog / VolatileScratch
├── project-memory.ts # REASONIX.md loader
+429 -450
View File
File diff suppressed because it is too large. Load diff
+6 -8
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.29.1",
"version": "0.30.3",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
@@ -72,12 +72,12 @@
"commander": "^12.1.0",
"eventsource-parser": "^3.0.0",
"ignore": "^7.0.5",
"ink": "^7.0.2",
"ink-text-input": "^6.0.0",
"node-html-parser": "^7.1.0",
"picomatch": "^4.0.4",
"react": "^18.3.1",
"react-reconciler": "^0.29.2",
"react": "^19.2.6",
"string-width": "^7.2.0",
"yoga-layout": "^3.2.1",
"zod": "^4.4.1"
},
"devDependencies": {
@@ -86,14 +86,12 @@
"@stryker-mutator/vitest-runner": "^9.6.1",
"@types/node": "^22.9.0",
"@types/picomatch": "^4.0.3",
"@types/react": "^18.3.12",
"@types/react-reconciler": "^0.28.9",
"@types/react": "^19.2.14",
"@vitest/coverage-v8": "^2.1.5",
"esbuild": "^0.21.5",
"highlight.js": "^11.10.0",
"htm": "^3.1.1",
"ink": "^5.1.0",
"ink-text-input": "^6.0.0",
"ink-testing-library": "^4.0.0",
"marked": "^15.0.12",
"preact": "^10.22.0",
"simple-git-hooks": "^2.13.1",
+30 -15
View File
@@ -1,10 +1,17 @@
import { render } from "ink";
import React, { useState } from "react";
import { loadApiKey, readConfig, searchEnabled } from "../../config.js";
import {
loadApiKey,
readConfig,
searchEnabled,
webSearchEndpoint,
webSearchEngine,
} from "../../config.js";
import { loadDotenv } from "../../env.js";
import type { CacheFirstLoop } from "../../loop.js";
import { McpClient } from "../../mcp/client.js";
import { type InspectionReport, inspectMcpServer } from "../../mcp/inspect.js";
import { preflightStdioSpec } from "../../mcp/preflight.js";
import { type McpClientHost, bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
@@ -101,6 +108,7 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
: ctx.getRequestedCount() === 1 && ctx.getMcpPrefix()
? (ctx.getMcpPrefix() as string)
: "";
if (spec.transport === "stdio") preflightStdioSpec(spec);
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
@@ -300,6 +308,13 @@ export interface ChatOptions {
* URL is visible in the status bar.
*/
noDashboard?: boolean;
/**
* Render into the terminal's alternate screen buffer. Default true —
* alt-screen avoids the scrollback-mode resize/wrap ghost class. Pass
* false (CLI: `--no-alt-screen`) when the chat output needs to remain
* in shell scrollback after exit.
*/
altScreen?: boolean;
}
interface RootProps extends ChatOptions {
@@ -444,7 +459,10 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
// a question needs info fresher than its training data.
if (searchEnabled()) {
if (!tools) tools = new ToolRegistry();
registerWebTools(tools);
registerWebTools(tools, {
webSearchEngine: webSearchEngine(),
webSearchEndpoint: webSearchEndpoint(),
});
}
// Memory tools — available in every session, not just code mode.
@@ -475,17 +493,6 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
const showPicker =
!opts.session && !opts.forceResume && listSessionsForWorkspace(launchWorkspace).length > 0;
// No startup clear, no resize listener. Earlier attempts wrote
// various combinations of \x1b[2J / \x1b[3J / cursor-home to
// present a 'clean canvas' on launch and to neutralize Ink's
// eraseLines miscount on resize, but on xterm.js-based terminals
// (VSCode integrated terminal in particular) those sequences
// interfere with scrollback in ways that make wheel-up scroll
// dead. Letting Ink mount directly leaves the user's previous
// shell prompt visible above (scrolling up just works) and
// accepts the resize-ghost / launch-noise tradeoffs as known
// limitations — `/clear` is the manual reset.
const { waitUntilExit } = render(
<Root
initialKey={initialKey}
@@ -498,8 +505,16 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
{...opts}
session={resolvedSession}
/>,
// patchConsole:false — winpty/MINTTY redraw-glitch source.
{ exitOnCtrlC: true, patchConsole: false },
{
exitOnCtrlC: true,
// patchConsole:false — winpty/MINTTY redraw-glitch source.
patchConsole: false,
incrementalRendering: true,
// Default true — alt-screen is the only mode without scrollback-
// reflow ghosting. `--no-alt-screen` opts back into scrollback mode
// for users who need chat output preserved in shell history on exit.
alternateScreen: opts.altScreen !== false,
},
);
try {
await waitUntilExit();
+3
View File
@@ -66,6 +66,8 @@ export interface CodeOptions {
systemAppend?: string;
/** Path to a UTF-8 text file whose contents are appended to the code system prompt. */
systemAppendFile?: string;
/** Default true. Pass false (CLI: `--no-alt-screen`) to keep chat output in shell scrollback. */
altScreen?: boolean;
}
export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
@@ -190,5 +192,6 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
forceResume: opts.forceResume,
forceNew: opts.forceNew,
noDashboard: opts.noDashboard,
altScreen: opts.altScreen,
});
}
-1
View File
@@ -1,7 +1,6 @@
/** `reasonix mcp browse` — Ink TUI for the MCP marketplace. Lazy-loads pages on scroll. */
import { Box, Text, render, useApp, useInput } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { readConfig, writeConfig } from "../../config.js";
import { loadDotenv } from "../../env.js";
+2
View File
@@ -1,6 +1,7 @@
import { McpClient } from "../../mcp/client.js";
import { inspectMcpServer } from "../../mcp/inspect.js";
import type { InspectionReport } from "../../mcp/inspect.js";
import { preflightStdioSpec } from "../../mcp/preflight.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
@@ -15,6 +16,7 @@ export interface McpInspectOptions {
export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void> {
const spec = parseMcpSpec(opts.spec);
if (spec.transport === "stdio") preflightStdioSpec(spec);
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
+2
View File
@@ -11,6 +11,7 @@ import {
import { loadDotenv } from "../../env.js";
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
import { McpClient } from "../../mcp/client.js";
import { preflightStdioSpec } from "../../mcp/preflight.js";
import { bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
@@ -103,6 +104,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
: requestedSpecs.length === 1 && opts.mcpPrefix
? opts.mcpPrefix
: "";
if (spec.transport === "stdio") preflightStdioSpec(spec);
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
+4
View File
@@ -113,6 +113,7 @@ program
.option("--harvest", t("ui.harvestHint"))
.option("--budget <usd>", t("ui.budgetHint"), (v) => Number.parseFloat(v))
.option("--no-dashboard", t("ui.noDashboard"))
.option("--no-alt-screen", "keep chat output in shell scrollback (legacy mode, ghost-prone)")
.option("--system-append <prompt>", t("ui.systemAppendHint"))
.option("--system-append-file <path>", t("ui.systemAppendFileHint"))
.action(async (dir: string | undefined, opts) => {
@@ -128,6 +129,7 @@ program
noDashboard: opts.dashboard === false,
systemAppend: opts.systemAppend,
systemAppendFile: opts.systemAppendFile,
altScreen: opts.altScreen !== false,
});
});
@@ -155,6 +157,7 @@ program
.option("--mcp-prefix <str>", t("ui.mcpPrefixHint"))
.option("--no-config", t("ui.noConfigHint"))
.option("--no-dashboard", t("ui.noDashboard"))
.option("--no-alt-screen", "keep chat output in shell scrollback (legacy mode, ghost-prone)")
.action(async (opts) => {
const defaults = resolveDefaults({
model: opts.model,
@@ -190,6 +193,7 @@ program
forceResume: continueOpts.forceResume,
forceNew: !!opts.new,
noDashboard: opts.dashboard === false,
altScreen: opts.altScreen !== false,
});
});
+114 -65
View File
@@ -96,6 +96,7 @@ import {
} from "./hooks/handle-stream-events.js";
import { handleToolEvent } from "./hooks/handle-tool-event.js";
import { useAgentSession } from "./hooks/useAgentSession.js";
import { useChatScroll } from "./hooks/useChatScroll.js";
import { useCodeMode } from "./hooks/useCodeMode.js";
import { useInputRecall } from "./hooks/useInputRecall.js";
import { useLoopMode } from "./hooks/useLoopMode.js";
@@ -104,6 +105,7 @@ import { useScrollback } from "./hooks/useScrollback.js";
import { useTranscriptWriter } from "./hooks/useTranscriptWriter.js";
import { useKeystroke } from "./keystroke-context.js";
import { CardStream } from "./layout/CardStream.js";
import { LiveExpandContext } from "./layout/LiveExpandContext.js";
import {
ModeStatusBar,
OngoingToolRow,
@@ -126,6 +128,7 @@ import { TurnTranslator } from "./state/TurnTranslator.js";
import { cardsToDashboardMessages } from "./state/cards-to-messages.js";
import { hydrateCardsFromMessages } from "./state/hydrate.js";
import { AgentStoreProvider, useAgentState, useAgentStore } from "./state/provider.js";
import { FG } from "./theme/tokens.js";
import { TickerProvider } from "./ticker.js";
import { useCompletionPickers } from "./useCompletionPickers.js";
import { useEditHistory } from "./useEditHistory.js";
@@ -297,8 +300,15 @@ function AppInner({
s.cards.some((c) => c.kind === "user" || c.kind === "streaming"),
);
const isStreaming = useAgentState((s) => s.cards.some((c) => c.kind === "streaming" && !c.done));
const chatScroll = useChatScroll();
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
// ctrl-o toggles full-tail view on the live streaming card.
// Auto-resets at the end of every turn so the next reply starts collapsed.
const [liveExpand, setLiveExpand] = useState(false);
useEffect(() => {
if (!isStreaming && liveExpand) setLiveExpand(false);
}, [isStreaming, liveExpand]);
const [languageVersion, setLanguageVersion] = useState(0);
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
// Live MCP server list: initialized from the boot-time prop, then
@@ -359,39 +369,6 @@ function AppInner({
};
}, [stdout]);
// Resize-suppression state. While the user is dragging the
// terminal corner, OS emits a stream of resize events at high
// frequency. Each one would trigger Ink to re-render — and our
// per-tick animations (wordmark gradient, prompt bar flow,
// cursor blink) keep firing at 120 ms — both with stale
// `eraseLines(N)` counts because the previous frame's logical
// height is no longer the visible height after wrap reflows.
// Result: ghost copies of the StatsPanel pile up.
//
// Fix: detect resize bursts and freeze the global ticker while
// they're in flight. With the ticker frozen, no re-render fires
// from animations during the resize storm. Once the user stops
// dragging (no resize event for ~400 ms), the ticker resumes
// and one clean re-render kicks in. The hard-clear in chat.tsx's
// resize listener handles the single transition ghost.
const [isResizing, setIsResizing] = useState(false);
useEffect(() => {
if (!stdout || !stdout.isTTY) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
setIsResizing(true);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
setIsResizing(false);
timer = null;
}, 400);
};
stdout.on("resize", onResize);
return () => {
stdout.off("resize", onResize);
if (timer) clearTimeout(timer);
};
}, [stdout]);
// Subagent UI wiring: live activity row + sink ref the loop closure
// captures. Must be declared BEFORE loop construction so the
// subagentRunner closure can read the ref. The wallet-currency thunk
@@ -433,6 +410,7 @@ function AppInner({
undoBanner,
recordEdit,
armUndoBanner,
toggleUndoPause,
codeUndo,
codeHistory,
codeShowEdit,
@@ -602,6 +580,22 @@ function AppInner({
options: ChoiceOption[];
allowCustom: boolean;
} | null>(null);
// Truthy when any pending modal owns the screen — gates global
// hotkeys (chat-scroll, etc.) so they don't fire behind a picker.
const modalOpen =
!!pendingShell ||
!!pendingPlan ||
!!pendingReviseEditor ||
!!pendingSessionsPicker ||
!!pendingMcpHub ||
!!stagedInput ||
!!pendingEditReview ||
walkthroughActive ||
!!pendingChoice ||
!!stagedChoiceCustom ||
!!pendingRevision ||
!!stagedCheckpointRevise ||
!!pendingCheckpoint;
// Plan-mode indicator — displayed in the StatsPanel, mirrored onto
// the ToolRegistry so dispatch enforces read-only. Toggled via the
// `/plan` slash and PlanConfirm picker. Ephemeral — not persisted
@@ -1152,6 +1146,20 @@ function AppInner({
// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
const quitProcess = useQuit(transcriptRef);
// PgUp / PgDn always scroll chat history; ↑/↓-on-empty-buffer also
// routes here via PromptInput's chatScrollHandoff. WT translates wheel
// events to ↑/↓ in raw mode, so this is what makes wheel-scroll work.
useKeystroke((ev) => {
if (ev.pageUp) chatScroll.scrollUp();
else if (ev.pageDown) chatScroll.scrollDown();
else if (ev.end) chatScroll.jumpToBottom();
// Wheel-translated ↑/↓ has nowhere to go when PromptInput can't
// process it: in reading mode (unmounted) or while busy (disabled).
// When pinned + idle, PromptInput owns arrows for cursor / handoff.
else if ((!chatScroll.pinned || busy) && ev.upArrow) chatScroll.scrollUp();
else if ((!chatScroll.pinned || busy) && ev.downArrow) chatScroll.scrollDown();
}, !modalOpen);
// 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
@@ -1254,7 +1262,7 @@ function AppInner({
next === "yolo"
? "▸ edit mode: YOLO — edits AND shell commands auto-run. /undo still rolls back edits. Use carefully."
: next === "auto"
? "▸ edit mode: AUTO — edits apply immediately; press u within 5s to undo. Shell commands still ask."
? "▸ edit mode: AUTO — edits apply immediately; press u within 5s to undo (space pauses the timer). Shell commands still ask."
: "▸ edit mode: review — edits queue for /apply (or y) / /discard (or n)";
log.pushInfo(message);
return;
@@ -1287,6 +1295,50 @@ function AppInner({
log.pushInfo(out);
return;
}
// Space toggles pause on the active undo countdown. Same gating as
// the `u` keybind so typing in the prompt isn't intercepted.
if (
codeMode &&
input.length === 0 &&
chKey === " " &&
undoBanner &&
!pendingShell &&
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
!walkthroughActive &&
!pendingChoice &&
!stagedChoiceCustom &&
!pendingRevision
) {
toggleUndoPause();
return;
}
// Ctrl-O toggles full-tail view on the live streaming reply so a long
// plan / todo can be read while it's still being written. Resets at
// turn end so each new reply starts collapsed.
if (
key.ctrl &&
key.input === "o" &&
isStreaming &&
!pendingShell &&
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
!walkthroughActive &&
!pendingChoice &&
!stagedChoiceCustom &&
!pendingRevision
) {
setLiveExpand((v) => !v);
return;
}
if (busy) return;
// ShellConfirm owns the full keyboard while it's showing. If we
// kept handling ↑/↓ / Tab here they'd race with its SingleSelect
@@ -2359,7 +2411,12 @@ function AppInner({
const m = loadSessionMeta(session);
const cost = (m.totalCostUsd ?? 0) + (ev.stats?.cost ?? 0);
const turn = (m.turnCount ?? 0) + 1;
patchSessionMeta(session, { totalCostUsd: cost, turnCount: turn });
const currency = walletCurrencyRef.current;
patchSessionMeta(session, {
totalCostUsd: cost,
turnCount: turn,
...(currency ? { balanceCurrency: currency } : {}),
});
}
} else if (ev.role === "tool_start") {
handleToolStart(ev, {
@@ -3003,40 +3060,25 @@ function AppInner({
[],
);
// Suspend cosmetic animations during modal interactions and idle so
// a quiescent TUI is byte-stable. PLAIN_UI is the env-flag opt-out
// for fragile terminals.
const tickerSuspended = PLAIN_UI || modalOpen || (!busy && !isStreaming);
return (
<>
<TickerProvider
disabled={
PLAIN_UI ||
isResizing ||
!!pendingPlan ||
!!pendingReviseEditor ||
pendingSessionsPicker ||
!!pendingMcpHub ||
!!pendingShell ||
!!pendingEditReview ||
walkthroughActive ||
!!pendingChoice ||
!!stagedChoiceCustom ||
!!pendingRevision ||
!!stagedCheckpointRevise ||
!!pendingCheckpoint ||
// Idle gate: when nothing is actively happening, suspend the
// 8Hz/1Hz heartbeats. The cursor blink, gradient pulse, and
// spinner glyphs are pure cosmetics — running them at idle
// forces Ink to repaint the screen ~8x/sec, which erases any
// text selection the user has made in the terminal. With the
// ticker paused, an idle TUI is byte-stable and shift-drag /
// click-drag selections survive until something actually
// changes (incoming stream, key press, modal popup).
(!busy && !isStreaming)
}
>
<TickerProvider disabled={tickerSuspended}>
<ViewportBudgetProvider>
<Box flexDirection="row">
<Box flexDirection="row" height={stdout?.rows ?? 24}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="column">
<CardStream />
<Box flexDirection="column" flexGrow={1}>
<LiveExpandContext.Provider value={liveExpand}>
<CardStream
suppressLive={modalOpen}
scrollRows={chatScroll.scrollRows}
onMaxScrollChange={chatScroll.setMaxScroll}
/>
</LiveExpandContext.Provider>
{/*
Welcome card on the empty state. Visible only when nothing
has happened yet (no past events, nothing in flight, no
@@ -3190,6 +3232,7 @@ function AppInner({
<SessionPicker
sessions={sessionsPickerList}
workspace={currentRootDir}
walletCurrency={walletCurrencyRef.current}
onChoose={(outcome) => {
if (outcome.kind === "open") {
setPendingSessionsPicker(false);
@@ -3304,6 +3347,10 @@ function AppInner({
block={pendingEdits.current[0]!}
onChoose={handleWalkChoice}
/>
) : !chatScroll.pinned ? (
<Text color={FG.faint}>
{" 📖 reading history — End / PgDn to return · ↓ to advance one line"}
</Text>
) : (
<>
{codeMode ? (
@@ -3325,6 +3372,8 @@ function AppInner({
disabled={busy}
onHistoryPrev={recallPrev}
onHistoryNext={recallNext}
onChatScrollUp={chatScroll.scrollUp}
onChatScrollDown={chatScroll.scrollDown}
/>
{slashMatches !== null ? (
<SlashSuggestions matches={slashMatches} selectedIndex={slashSelected} />
-1
View File
@@ -1,6 +1,5 @@
/** Modal picker for `ask_choice` — options + optional "type my own" escape hatch. */
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import type { ChoiceOption } from "../../tools/choice.js";
import { SingleSelect } from "./Select.js";
-1
View File
@@ -1,7 +1,6 @@
/** `/mcp` browser modal — keyboard-driven server list per design §24. */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { useKeystroke } from "./keystroke-context.js";
import { toggleMcpDisabled } from "./mcp-disable.js";
-1
View File
@@ -1,7 +1,6 @@
/** `/mcp` slash modal — single hub with two tabs: Live (attached servers) + Marketplace (registry). */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { McpBrowser } from "./McpBrowser.js";
import { McpMarketplace } from "./McpMarketplace.js";
-1
View File
@@ -1,7 +1,6 @@
/** `/mcp browse` modal — registry marketplace inside the chat session. */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { readConfig, writeConfig } from "../../config.js";
import {
-1
View File
@@ -1,7 +1,6 @@
/** Modal picker for `PlanCheckpointError`: continue / revise / stop. */
import { Box } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import type { PlanStep } from "../../tools/plan.js";
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
-1
View File
@@ -1,7 +1,6 @@
/** Modal-style picker for `submit_plan`: accept / refine / cancel. */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import type { PlanStep } from "../../tools/plan.js";
import { PlanStepList } from "./PlanStepList.js";
+13 -10
View File
@@ -1,7 +1,6 @@
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 { useCursor } from "../../renderer/index.js";
import { useKeystroke } from "./keystroke-context.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { type MultilineKey, lineAndColumn, processMultilineKey } from "./multiline-keys.js";
@@ -26,14 +25,12 @@ export interface PromptInputProps {
onSubmit: (v: string) => void;
disabled?: boolean;
placeholder?: string;
/**
* History recall. Fired when ↑/↓ hits a buffer boundary (empty
* buffer, or cursor already at the first/last line of a multi-line
* buffer). Parent walks its prompt history and swaps the value via
* `onChange`. Absent → keys are consumed silently.
*/
/** Ctrl+P / Ctrl+N — parent walks its prompt history and swaps `value` via `onChange`. */
onHistoryPrev?: () => void;
onHistoryNext?: () => void;
/** ↑/↓ on an empty buffer — parent scrolls chat history. */
onChatScrollUp?: () => void;
onChatScrollDown?: () => void;
}
export function PromptInput({
@@ -44,10 +41,16 @@ export function PromptInput({
placeholder,
onHistoryPrev,
onHistoryNext,
onChatScrollUp,
onChatScrollDown,
}: PromptInputProps) {
// Cap at 24 — collapseLinesForDisplay hides content past ~20 logical lines.
// Quantize spec.max to 4-row buckets so per-keystroke line-count changes
// don't churn viewport-budget; without this every single character that
// adds/removes a newline re-dispatches the allocator and reflows layout.
const inputLineCount = value.length > 0 ? value.split("\n").length : 1;
useReserveRows("input", { min: 1, max: Math.min(inputLineCount + 3, 24) });
const reserveMax = Math.min(Math.ceil(inputLineCount / 4) * 4 + 3, 24);
useReserveRows("input", { min: 1, max: reserveMax });
const [cursor, setCursor] = useState(value.length);
@@ -132,6 +135,8 @@ export function PromptInput({
}
if (action.historyHandoff === "prev") onHistoryPrev?.();
if (action.historyHandoff === "next") onHistoryNext?.();
if (action.chatScrollHandoff === "up") onChatScrollUp?.();
if (action.chatScrollHandoff === "down") onChatScrollDown?.();
}, !disabled);
// ── Render ──────────────────────────────────────────────────────
@@ -153,8 +158,6 @@ export function PromptInput({
const accentColor = disabled ? FG.faint : TONE.brand;
const cursorVisible = true;
const { line: cursorLine, col: cursorCol } = lineAndColumn(value, cursor);
// PromptInput paints its own cursor (`▌` / inverse char) — hide the real terminal cursor so it doesn't double up at the bottom of the screen.
useCursor({ col: 0, rowFromBottom: 0, visible: false });
const renderItems = collapseLinesForDisplay(lines, cursorLine);
const showHugeBufferHints = lines.length > 20;
+22 -8
View File
@@ -3,7 +3,7 @@ import { Box, Text, useStdout } from "ink";
import React, { useState } from "react";
import type { SessionInfo } from "../../memory/session.js";
import { useKeystroke } from "./keystroke-context.js";
import { FG, TONE, USD_TO_CNY } from "./theme/tokens.js";
import { FG, TONE, formatCost } from "./theme/tokens.js";
export type SessionPickerOutcome =
| { kind: "open"; name: string }
@@ -16,6 +16,8 @@ export interface SessionPickerProps {
sessions: ReadonlyArray<SessionInfo>;
workspace: string;
onChoose: (outcome: SessionPickerOutcome) => void;
/** Live wallet currency from App.tsx; falls back to each session's stored `meta.balanceCurrency` per row. */
walletCurrency?: string;
}
const PAGE_MARGIN = 6;
@@ -24,6 +26,7 @@ export function SessionPicker({
sessions,
workspace,
onChoose,
walletCurrency,
}: SessionPickerProps): React.ReactElement {
const [focus, setFocus] = useState(0);
const [renaming, setRenaming] = useState<{ from: string; buf: string } | null>(null);
@@ -101,7 +104,14 @@ export function SessionPicker({
<Text color={FG.faint}>{" to start a new one"}</Text>
</Box>
) : (
shown.map((s, i) => <SessionRow key={s.name} info={s} focused={start + i === focus} />)
shown.map((s, i) => (
<SessionRow
key={s.name}
info={s}
focused={start + i === focus}
walletCurrency={walletCurrency}
/>
))
)}
{hiddenBelow > 0 ? (
<Box>
@@ -135,15 +145,19 @@ export function SessionPicker({
function SessionRow({
info,
focused,
}: { info: SessionInfo; focused: boolean }): React.ReactElement {
walletCurrency,
}: {
info: SessionInfo;
focused: boolean;
walletCurrency?: string;
}): React.ReactElement {
const branch = info.meta.branch ?? "main";
const summary =
info.meta.summary ?? `${info.messageCount} message${info.messageCount === 1 ? "" : "s"}`;
const turns = info.meta.turnCount ?? Math.ceil(info.messageCount / 2);
const costCny =
info.meta.totalCostUsd !== undefined
? `¥${(info.meta.totalCostUsd * USD_TO_CNY).toFixed(2)}`
: "";
const currency = walletCurrency ?? info.meta.balanceCurrency;
const costLabel =
info.meta.totalCostUsd !== undefined ? formatCost(info.meta.totalCostUsd, currency, 2) : "";
const time = relativeTime(info.mtime);
return (
<Box>
@@ -156,7 +170,7 @@ function SessionRow({
<Box flexGrow={1} />
<Text color={FG.faint}>{`${time.padStart(11)} `}</Text>
<Text color={FG.faint}>{`${turns} turns`}</Text>
{costCny ? <Text color={FG.faint}>{` · ${costCny}`}</Text> : null}
{costLabel ? <Text color={FG.faint}>{` · ${costLabel}`}</Text> : null}
</Box>
);
}
-1
View File
@@ -1,5 +1,4 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { DenyContextInput } from "./DenyContextInput.js";
import { SingleSelect } from "./Select.js";
+2
View File
@@ -86,6 +86,7 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
const key = `slash.${spec.cmd}.description`;
const translated = t(key);
const summary = translated === key ? spec.summary : translated;
const aliasHint = spec.aliases?.length ? ` · /${spec.aliases.join(" /")}` : "";
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
@@ -99,6 +100,7 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
<Text color={isSelected ? COLOR.user : COLOR.info} dimColor={!isSelected}>
{summary}
</Text>
{aliasHint ? <Text dimColor>{aliasHint}</Text> : null}
</Box>
);
}
+63
View File
@@ -11,6 +11,7 @@
* `--branch`, etc. can be picked here in a few arrow-key presses.
*/
import { mkdirSync, statSync } from "node:fs";
import { Box, Text, useApp, useInput } from "ink";
import TextInput from "ink-text-input";
// biome-ignore lint/style/useImportType: JSX (jsx: "react") needs React as a value at runtime
@@ -287,6 +288,49 @@ function McpArgsStep({
onError: (e: string | null) => void;
}) {
const [value, setValue] = useState("");
const [pendingCreate, setPendingCreate] = useState<string | null>(null);
useInput((input, key) => {
if (!pendingCreate) return;
const ch = input.toLowerCase();
if (ch === "y" || key.return) {
try {
mkdirSync(pendingCreate, { recursive: true });
const created = pendingCreate;
setPendingCreate(null);
setValue("");
onError(null);
onSubmit(created);
} catch (e) {
onError(`Couldn't create ${pendingCreate}: ${(e as Error).message}`);
setPendingCreate(null);
}
} else if (ch === "n" || key.escape) {
setPendingCreate(null);
onError(null);
}
});
if (pendingCreate) {
return (
<StepFrame title={`Configure ${entry.name}`} step={2} total={3}>
<Box flexDirection="column">
<Text>
Directory <Text bold>{pendingCreate}</Text> doesn't exist.
</Text>
<Box marginTop={1}>
<Text dimColor>[Y/Enter] create it (mkdir -p) · [N/Esc] enter a different path</Text>
</Box>
{error ? (
<Box marginTop={1}>
<Text color="red">{error}</Text>
</Box>
) : null}
</Box>
</StepFrame>
);
}
return (
<StepFrame title={`Configure ${entry.name}`} step={2} total={3}>
<Box flexDirection="column">
@@ -314,6 +358,17 @@ function McpArgsStep({
onError(`${entry.name} needs a value — got an empty string.`);
return;
}
if (entry.name === "filesystem") {
const check = checkFilesystemPath(trimmed);
if (check.kind === "missing") {
setPendingCreate(trimmed);
return;
}
if (check.kind === "not-a-dir") {
onError(`${trimmed} exists but is not a directory.`);
return;
}
}
onSubmit(trimmed);
setValue("");
}}
@@ -330,6 +385,14 @@ function McpArgsStep({
);
}
function checkFilesystemPath(p: string): { kind: "ok" | "missing" | "not-a-dir" } {
try {
return { kind: statSync(p).isDirectory() ? "ok" : "not-a-dir" };
} catch {
return { kind: "missing" };
}
}
function ReviewConfirm({ onConfirm }: { onConfirm: () => void }) {
useInput((_i, key) => {
if (key.return) onConfirm();
-39
View File
@@ -1,39 +0,0 @@
/** Alt-screen lifecycle — restore must be idempotent and run on SIGINT/SIGTERM/exit so the user's main buffer always comes back. */
import { useEffect } from "react";
function isInteractiveTty(): boolean {
return Boolean(process.stdout?.isTTY);
}
export function useAltScreen(): void {
useEffect(() => {
if (!isInteractiveTty()) return;
// 1002 = button-event tracking (press + drag + release); 1006 = SGR coords.
// Shift+drag bypasses tracking for native terminal selection.
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H\x1b[?1002h\x1b[?1006h");
let restored = false;
const restore = () => {
if (restored) return;
restored = true;
try {
process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?1049l");
} catch {
/* terminal closed */
}
};
process.once("exit", restore);
process.once("SIGINT", () => {
restore();
process.exit(130);
});
process.once("SIGTERM", () => {
restore();
process.exit(143);
});
return restore;
}, []);
}
+7 -5
View File
@@ -1,5 +1,4 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import type { Card } from "../state/cards.js";
import { FG } from "../theme/tokens.js";
@@ -21,11 +20,14 @@ import { UsageCard } from "./UsageCard.js";
import { UserCard } from "./UserCard.js";
import { WarnCard } from "./WarnCard.js";
export function CardRenderer({ card }: { card: Card }): React.ReactElement {
// No marginTop here — Card primitive owns inter-card spacing. Doubling it
// bakes 2 blank rows into every static-emitted block of bytes.
// Memoized so the cards array re-rendering (every store update) only
// reconciles cards whose object identity actually changed — the reducer
// keeps prior cards reference-stable, so unchanged history skips work.
export const CardRenderer = React.memo(function CardRenderer({
card,
}: { card: Card }): React.ReactElement {
return <Box flexDirection="column">{renderCard(card)}</Box>;
}
});
function renderCard(card: Card): React.ReactElement {
switch (card.kind) {
+72 -5
View File
@@ -1,7 +1,9 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import React, { useContext } from "react";
import { clipToCells, wrapToCells } from "../../../frame/width.js";
import { countTokens } from "../../../tokenizer.js";
import { LiveExpandContext } from "../layout/LiveExpandContext.js";
import { useReserveRows } from "../layout/viewport-budget.js";
import { Markdown } from "../markdown.js";
import { Card } from "../primitives/Card.js";
@@ -10,17 +12,49 @@ import { PILL_MODEL, Pill, modelBadgeFor } from "../primitives/Pill.js";
import { Spinner } from "../primitives/Spinner.js";
import type { StreamingCard as StreamingCardData } from "../state/cards.js";
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
import { useSlowTick } from "../ticker.js";
/** Streaming preview tail length — bounded live region so chunks don't thrash whole-card layout. */
const STREAMING_PREVIEW_LINES = 4;
/** Expanded mode shows up to this many lines so the card can't swallow the whole viewport. */
const EXPANDED_MAX_LINES = 60;
const MIN_ELAPSED_MS_FOR_RATE = 500;
const MIN_TOKENS_FOR_RATE = 4;
function formatTokenCount(n: number): string {
if (n >= 10000) return `${(n / 1000).toFixed(1)}k`;
if (n >= 1000) return `${(n / 1000).toFixed(2)}k`;
return String(n);
}
function tokenRate(
text: string,
startTs: number,
endTs: number,
): { tokens: number; tps: number | null } {
const tokens = countTokens(text);
const elapsedMs = endTs - startTs;
if (elapsedMs < MIN_ELAPSED_MS_FOR_RATE || tokens < MIN_TOKENS_FOR_RATE) {
return { tokens, tps: null };
}
return { tokens, tps: Math.round((tokens * 1000) / elapsedMs) };
}
const PILL_RATE = { bg: "#11141a", fg: "#8b949e" } as const;
export function StreamingCard({ card }: { card: StreamingCardData }): React.ReactElement {
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
const expanded = useContext(LiveExpandContext);
const reserveCap = expanded ? EXPANDED_MAX_LINES + 2 : STREAMING_PREVIEW_LINES + 2;
useReserveRows("stream", {
min: STREAMING_PREVIEW_LINES + 1,
max: STREAMING_PREVIEW_LINES + 2,
max: reserveCap,
});
// Re-render at 1Hz so the rate keeps updating even when chunks stall.
// Frozen once `card.done` is true — settled cards render via Static.
useSlowTick();
const modelBadge = card.model ? modelBadgeFor(card.model) : null;
const modelPill = modelBadge ? (
@@ -28,9 +62,24 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
) : null;
if (card.done && !card.aborted) {
const { tokens, tps } = tokenRate(card.text, card.ts, card.endedAt ?? Date.now());
const ratePill =
tokens >= MIN_TOKENS_FOR_RATE && tps !== null ? (
<Pill label={`${formatTokenCount(tokens)} tok · ${tps} t/s`} {...PILL_RATE} bold={false} />
) : null;
return (
<Card tone={TONE.ok}>
<CardHeader glyph="‹" tone={TONE.ok} title="reply" right={modelPill} />
<CardHeader
glyph="‹"
tone={TONE.ok}
title="reply"
right={
<>
{ratePill}
{modelPill}
</>
}
/>
<Markdown text={card.text} />
</Card>
);
@@ -39,12 +88,23 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
const lineCells = Math.max(20, cols - 4);
const allLines = card.text.length > 0 ? card.text.split("\n") : [""];
const visualLines = allLines.flatMap((l) => wrapToCells(l, lineCells));
const visible = visualLines.slice(-STREAMING_PREVIEW_LINES);
const cap = expanded ? EXPANDED_MAX_LINES : STREAMING_PREVIEW_LINES;
const visible = visualLines.slice(-cap);
const droppedAbove = Math.max(0, visualLines.length - visible.length);
const aborted = !!card.aborted;
const headColor = aborted ? TONE.err : TONE_ACTIVE.brand;
const glyph = aborted ? "‹" : "◈";
const headLabel = aborted ? "aborted" : "writing…";
const { tokens: liveTokens, tps: liveTps } = tokenRate(card.text, card.ts, Date.now());
const liveRatePill =
!aborted && liveTokens >= MIN_TOKENS_FOR_RATE && liveTps !== null ? (
<Pill label={`${liveTps} t/s`} {...PILL_RATE} bold={false} />
) : null;
const expandPill = !aborted ? (
<Pill label={expanded ? "expanded ⌃o" : "preview ⌃o"} {...PILL_RATE} bold={false} />
) : null;
return (
<Card tone={headColor}>
<CardHeader
@@ -53,13 +113,20 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
title={headLabel}
right={
<>
{liveRatePill}
{expandPill}
{aborted ? null : <Spinner kind="braille" color={TONE_ACTIVE.brand} />}
{modelPill}
</>
}
/>
{expanded && droppedAbove > 0 ? (
<Text
color={FG.faint}
>{`⋯ ${droppedAbove} earlier line${droppedAbove === 1 ? "" : "s"} above`}</Text>
) : null}
{visible.map((line, i) => (
<Box key={`${card.id}:${allLines.length - visible.length + i}`} flexDirection="row">
<Box key={`${card.id}:${visualLines.length - visible.length + i}`} flexDirection="row">
<Text color={aborted ? FG.meta : FG.body}>{clipToCells(line, lineCells)}</Text>
</Box>
))}
+1 -3
View File
@@ -32,9 +32,7 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
return { kind: "consumed" };
}
if (result.clear) {
// 2J + 3J + H: nuke viewport AND scrollback (visible buffer + the
// off-screen lines). Then reset state.cards so React doesn't
// immediately repaint everything that was just wiped.
// 2J + 3J + H: visible buffer + scrollback + cursor home.
ctx.stdoutWrite("\x1b[2J\x1b[3J\x1b[H");
ctx.log.reset();
if (result.info) ctx.log.pushInfo(result.info);
+51
View File
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from "react";
/** Rows advanced per PgUp / PgDn / arrow / wheel — small for smooth feel. */
export const SCROLL_PAGE_ROWS = 3;
export interface ChatScrollState {
/** How many rows of content are above the visible viewport. */
scrollRows: number;
/** True when the user is following the bottom (auto-advances on new content). */
pinned: boolean;
scrollUp: () => void;
scrollDown: () => void;
/** Jump straight to the latest content and resume auto-follow (End key). */
jumpToBottom: () => void;
/** CardStream calls this once it has measured inner/outer Box heights. */
setMaxScroll: (rows: number) => void;
}
/** Row-precision scroll state. CardStream reports maxScroll back via setMaxScroll once Ink has laid out the inner column. */
export function useChatScroll(): ChatScrollState {
const [scrollRows, setScrollRows] = useState(0);
const [pinned, setPinned] = useState(true);
const [maxScroll, setMaxScrollState] = useState(0);
useEffect(() => {
if (pinned) setScrollRows(maxScroll);
}, [pinned, maxScroll]);
useEffect(() => {
if (scrollRows > maxScroll) setScrollRows(maxScroll);
}, [scrollRows, maxScroll]);
const scrollUp = useCallback(() => {
setPinned(false);
setScrollRows((o) => Math.max(0, o - SCROLL_PAGE_ROWS));
}, []);
const scrollDown = useCallback(() => {
setScrollRows((o) => {
const next = Math.min(maxScroll, o + SCROLL_PAGE_ROWS);
if (next >= maxScroll) setPinned(true);
return next;
});
}, [maxScroll]);
const jumpToBottom = useCallback(() => setPinned(true), []);
const setMaxScroll = useCallback((rows: number) => setMaxScrollState(rows), []);
return { scrollRows, pinned, scrollUp, scrollDown, jumpToBottom, setMaxScroll };
}
+59 -32
View File
@@ -1,13 +1,62 @@
import { Static } from "ink";
import { Box, type DOMElement, Text, useBoxMetrics } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import React, { useEffect, useRef } from "react";
import { CardRenderer } from "../cards/CardRenderer.js";
import { ActiveCardContext } from "../primitives/Card.js";
import type { Card } from "../state/cards.js";
import { useAgentState } from "../state/provider.js";
import { FG } from "../theme/tokens.js";
/** Settled = no future event can mutate it; safe to commit via Ink's Static. */
function isSettled(card: Card): boolean {
/**
* Row-precision virtual scroll: outer Box clips with overflow="hidden",
* inner Box holds all cards and slides up via negative marginTop. Yoga
* computes inner height from card layout; useBoxMetrics reports it back
* so App can clamp scroll bounds. New cards arriving while the user is
* scrolled up sit below the visible window — bytes outside outer bounds
* aren't written, so terminal text selection survives streaming activity.
*/
export function CardStream({
scrollRows,
onMaxScrollChange,
suppressLive = false,
}: {
scrollRows: number;
onMaxScrollChange: (rows: number) => void;
suppressLive?: boolean;
}): React.ReactElement {
const cards = useAgentState((s) => s.cards);
// useBoxMetrics types its ref as RefObject<DOMElement> (non-null); the
// null! assertion satisfies the signature — Ink populates the ref on
// mount before the metrics hook reads it.
const outerRef = useRef<DOMElement>(null!);
const innerRef = useRef<DOMElement>(null!);
const outer = useBoxMetrics(outerRef);
const inner = useBoxMetrics(innerRef);
const maxScroll = Math.max(0, inner.height - outer.height);
useEffect(() => {
onMaxScrollChange(maxScroll);
}, [maxScroll, onMaxScrollChange]);
let visible = cards;
if (suppressLive && cards.length > 0 && !isFullySettled(cards[cards.length - 1]!)) {
visible = cards.slice(0, -1);
}
return (
<>
{scrollRows > 0 ? <Text color={FG.faint}>{" ↑ earlier — PgUp / wheel / ↑"}</Text> : null}
<Box ref={outerRef} flexDirection="column" flexGrow={1} overflow="hidden">
<Box ref={innerRef} flexDirection="column" marginTop={-scrollRows} flexShrink={0}>
{visible.map((card) => (
<CardRenderer key={card.id} card={card} />
))}
</Box>
</Box>
</>
);
}
function isFullySettled(card: Card): boolean {
switch (card.kind) {
case "streaming":
case "tool":
@@ -15,34 +64,12 @@ function isSettled(card: Card): boolean {
return card.done || !!card.aborted;
case "reasoning":
return !card.streaming || !!card.aborted;
case "task":
case "subagent":
return card.status !== "running";
case "plan":
return card.steps.every((s) => s.status === "done" || s.status === "skipped");
default:
return true;
}
}
export function CardStream(): React.ReactElement {
const cards = useAgentState((s) => s.cards);
const lastIdx = cards.length - 1;
const lastCard = lastIdx >= 0 ? (cards[lastIdx] as Card) : null;
const lastIsLive = !!lastCard && !isSettled(lastCard);
const committed: Card[] = lastIsLive ? cards.slice(0, lastIdx) : cards.slice();
const live: Card[] = lastIsLive && lastCard ? [lastCard] : [];
// Static items are emitted via bridge.emitStatic, which renders them in an
// off-tree React reconciler — context from the live tree does NOT propagate.
// The ActiveCardContext.Provider must therefore live inside the children
// function so it travels with the rendered subtree.
return (
<>
<Static items={committed}>
{(card) => (
<ActiveCardContext.Provider value={false} key={card.id}>
<CardRenderer card={card} />
</ActiveCardContext.Provider>
)}
</Static>
{live.map((card) => (
<CardRenderer key={card.id} card={card} />
))}
</>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from "react";
/** ctrl-o toggles this; live streaming card swaps 4-line tail for full-tail view. */
export const LiveExpandContext = createContext<boolean>(false);
+14 -7
View File
@@ -113,17 +113,20 @@ function ModePill({
export function UndoBanner({
banner,
}: {
banner: { results: ApplyResult[]; expiresAt: number };
banner: { results: ApplyResult[]; expiresAt: number; pausedRemainingMs: number | null };
}) {
useTick();
const totalMs = 5000;
const remainingMs = Math.max(0, banner.expiresAt - Date.now());
const paused = banner.pausedRemainingMs !== null;
const remainingMs = paused
? (banner.pausedRemainingMs ?? 0)
: Math.max(0, banner.expiresAt - Date.now());
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;
const urgent = !paused && remainingSec <= 1;
const pct = (remainingMs / totalMs) * 100;
const tone = urgent ? TONE.err : TONE.accent;
const tone = paused ? TONE.warn : urgent ? TONE.err : TONE.accent;
return (
<Box marginY={1} paddingX={1}>
<Text backgroundColor={TONE.accent} color="black" bold>
@@ -133,11 +136,15 @@ export function UndoBanner({
<Text backgroundColor={TONE.brand} color="black" bold>
{" u "}
</Text>
<Text color={FG.faint}>{" to undo "}</Text>
<Text color={FG.faint}>{paused ? " to undo · " : " to undo · "}</Text>
<Text backgroundColor={paused ? TONE.warn : FG.faint} color="black" bold>
{" space "}
</Text>
<Text color={FG.faint}>{paused ? " to resume " : " to pause "}</Text>
<CharBar pct={pct} width={20} color={tone} showLabel={false} />
<Text color={FG.faint}>{" "}</Text>
<Text color={tone} bold={urgent}>
{`${remainingSec}s`}
<Text color={tone} bold={urgent || paused}>
{paused ? `${remainingSec}s · paused` : `${remainingSec}s`}
</Text>
</Box>
);
+27 -31
View File
@@ -1,6 +1,6 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { inkCompat } from "../../renderer/index.js";
import { type InlineSpan, type MdLine, markdownToLines } from "./markdown-lines.js";
const FG_BODY = "#c9d1d9";
@@ -13,9 +13,6 @@ const TONE_WARN = "#f0b07d";
const SURFACE_ELEV = "#161b22";
export function MarkdownView({ text }: { text: string }): React.ReactElement {
// No useMemo: the renderer's Static-path uses a one-shot tree walker that
// doesn't set up a React dispatcher, so hook calls would throw. Parsing on
// every render is cheap (marked.lexer is allocation-light).
return <MarkdownLines lines={markdownToLines(text)} />;
}
@@ -25,35 +22,34 @@ export function MarkdownLines({
lines: ReadonlyArray<MdLine>;
}): React.ReactElement {
return (
<inkCompat.Box flexDirection="column">
<Box flexDirection="column">
{lines.map((line, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + stable per render
<LineRow key={`md-${i}-${line.kind}`} line={line} />
))}
</inkCompat.Box>
</Box>
);
}
function LineRow({ line }: { line: MdLine }): React.ReactElement | null {
switch (line.kind) {
case "blank":
return <inkCompat.Text> </inkCompat.Text>;
return <Text> </Text>;
case "hr":
return <inkCompat.Text color={FG_FAINT}>──────</inkCompat.Text>;
return <Text color={FG_FAINT}>──────</Text>;
case "heading":
return (
<inkCompat.Box>
<inkCompat.Text bold color={FG_STRONG}>
<Box>
<Text bold color={FG_STRONG}>
{`${"#".repeat(line.level)} `}
</inkCompat.Text>
</Text>
<Spans spans={line.spans} bold strongColor />
</inkCompat.Box>
</Box>
);
case "paragraph":
return (
<inkCompat.Box>
<Box>
<Spans spans={line.spans} />
</inkCompat.Box>
</Box>
);
case "list": {
const indent = " ".repeat(line.depth * 2);
@@ -68,20 +64,20 @@ function LineRow({ line }: { line: MdLine }): React.ReactElement | null {
const markerColor =
line.task === "done" ? TONE_OK : line.task === "todo" ? FG_FAINT : FG_META;
return (
<inkCompat.Box>
<inkCompat.Text color={markerColor}>{`${indent}${marker} `}</inkCompat.Text>
<Box>
<Text color={markerColor}>{`${indent}${marker} `}</Text>
<Spans spans={line.spans} dim={line.task === "done"} strike={line.task === "done"} />
</inkCompat.Box>
</Box>
);
}
case "code":
return <CodeBlock lang={line.lang} text={line.text} />;
case "blockquote":
return (
<inkCompat.Box>
<inkCompat.Text color={TONE_BRAND}>{"▎ "}</inkCompat.Text>
<Box>
<Text color={TONE_BRAND}>{"▎ "}</Text>
<Spans spans={line.spans} italic />
</inkCompat.Box>
</Box>
);
}
}
@@ -93,15 +89,15 @@ function spanKey(span: InlineSpan, i: number): string {
function CodeBlock({ lang, text }: { lang: string; text: string }): React.ReactElement {
const lines = text.split("\n");
return (
<inkCompat.Box flexDirection="column">
{lang.length > 0 ? <inkCompat.Text color={FG_META}>{` ${lang}`}</inkCompat.Text> : null}
<Box flexDirection="column">
{lang.length > 0 ? <Text color={FG_META}>{` ${lang}`}</Text> : null}
{lines.map((ln, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: code lines are positional + stable per render
<inkCompat.Text key={`code-${i}`} backgroundColor={SURFACE_ELEV}>
<Text key={`code-${i}`} backgroundColor={SURFACE_ELEV}>
{` ${ln} `}
</inkCompat.Text>
</Text>
))}
</inkCompat.Box>
</Box>
);
}
@@ -115,7 +111,7 @@ interface SpansProps {
}
function Spans({ spans, bold, italic, dim, strike, strongColor }: SpansProps): React.ReactElement {
if (spans.length === 0) return <inkCompat.Text> </inkCompat.Text>;
if (spans.length === 0) return <Text> </Text>;
return (
<>
{spans.map((span, i) => (
@@ -150,9 +146,9 @@ function SpanText({
}): React.ReactElement {
if (span.code) {
return (
<inkCompat.Text color={FG_STRONG} backgroundColor={SURFACE_ELEV}>
<Text color={FG_STRONG} backgroundColor={SURFACE_ELEV}>
{` ${span.text} `}
</inkCompat.Text>
</Text>
);
}
const color = span.fileRef
@@ -163,7 +159,7 @@ function SpanText({
? FG_STRONG
: FG_BODY;
return (
<inkCompat.Text
<Text
color={color}
bold={!!(span.bold || ambientBold)}
italic={!!(span.italic || ambientItalic)}
@@ -172,6 +168,6 @@ function SpanText({
underline={!!(span.link || span.fileRef)}
>
{span.text}
</inkCompat.Text>
</Text>
);
}
-1
View File
@@ -127,7 +127,6 @@ function ListItem({
// biome-ignore lint/suspicious/noArrayIndexKey: list-item children are positional and stable per render
return <List key={`l-${i}`} token={tok as Tokens.List} depth={depth + 1} />;
}
// biome-ignore lint/suspicious/noArrayIndexKey: list-item children are positional and stable per render
return <BlockToken key={`b-${i}-${tok.type}`} token={tok} />;
})}
</Box>
+10 -9
View File
@@ -28,8 +28,10 @@ export interface MultilineAction {
/** When `true`, fire `onSubmit(submitValue ?? value)`. */
submit: boolean;
submitValue?: string;
/** Set when ↑/↓ hits a buffer boundary — parent handles history recall. */
/** Set on Ctrl+P / Ctrl+N — parent handles prompt-history recall. */
historyHandoff?: "prev" | "next";
/** Set when ↑/↓ fires on an empty buffer — parent scrolls the chat history. */
chatScrollHandoff?: "up" | "down";
/** Reducer is pure — hands raw paste to PromptInput which allocates a sentinel and inserts that. */
pasteRequest?: { content: string };
}
@@ -68,12 +70,11 @@ export function processMultilineKey(
return cursor === value.length ? NOOP : { next: null, cursor: value.length, submit: false };
}
// History recall is bound to Ctrl+P / Ctrl+N (bash readline
// convention) rather than ↑/↓. Reason: Windows Terminal + ConPTY
// translates mouse wheel events to ↑/↓ keystrokes for raw-mode
// child processes — indistinguishable at the byte level. If ↑/↓
// recalled history, every wheel-scroll would corrupt the prompt
// with a prior message. Ctrl+P/N is wheel-immune.
// Prompt-history recall is on Ctrl+P / Ctrl+N (bash readline
// convention). ↑/↓ on an empty buffer scrolls chat history because
// Windows Terminal + ConPTY translates mouse wheel events to ↑/↓ in
// raw mode — the only way to make wheel-up scroll chat is to map ↑
// to chat-scroll. Ctrl+P/N is wheel-immune.
if (key.ctrl && key.input === "p") {
return { ...NOOP, historyHandoff: "prev" };
}
@@ -90,12 +91,12 @@ export function processMultilineKey(
return { next: null, cursor: Math.min(value.length, cursor + 1), submit: false };
}
if (key.upArrow) {
if (value.length === 0) return { ...NOOP, historyHandoff: "prev" };
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "up" };
const moved = moveCursorUp(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
if (key.downArrow) {
if (value.length === 0) return { ...NOOP, historyHandoff: "next" };
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "down" };
const moved = moveCursorDown(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
-1
View File
@@ -1,5 +1,4 @@
import { Box } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useContext } from "react";
/** Settled cards (in scrollback) drop the border + paddingX so history reads as flat lines. */
-1
View File
@@ -1,5 +1,4 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { FG } from "../theme/tokens.js";
+4 -11
View File
@@ -1,16 +1,13 @@
import { Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { useTick } from "../ticker.js";
const FRAMES = {
circle: ["◐", "◓", "◑", "◒"] as const,
braille: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"] as const,
};
const CADENCE_MS = {
circle: 200,
braille: 80,
} as const;
export interface SpinnerProps {
kind?: keyof typeof FRAMES;
color?: string;
@@ -19,12 +16,8 @@ export interface SpinnerProps {
export function Spinner({ kind = "circle", color, bold }: SpinnerProps): React.ReactElement {
const frames = FRAMES[kind];
const [frame, setFrame] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setFrame((f) => (f + 1) % frames.length), CADENCE_MS[kind]);
return () => clearInterval(id);
}, [kind, frames.length]);
const tick = useTick();
const frame = tick % frames.length;
return (
<Text bold={bold} color={color}>
+32 -5
View File
@@ -1,7 +1,7 @@
import type { SlashArgContext, SlashCommandSpec } from "./types.js";
export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
{ cmd: "help", summary: "show the full command reference" },
{ cmd: "help", summary: "show the full command reference", aliases: ["?"] },
{ cmd: "status", summary: "current model, flags, context, session" },
{
cmd: "preset",
@@ -52,6 +52,7 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argsHint: "<EN|zh-CN>",
summary: "switch the runtime language",
argCompleter: ["EN", "zh-CN"],
aliases: ["lang"],
},
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
{
@@ -76,6 +77,13 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argsHint: "[list|show <name>|forget <name>|clear <scope> confirm]",
summary: "show / manage pinned memory (REASONIX.md + ~/.reasonix/memory)",
},
{
cmd: "search-engine",
argsHint: "<mojeek|searxng> [<endpoint>]",
summary: "switch web search backend — mojeek (default, no deps) or searxng (self-hosted)",
argCompleter: ["mojeek", "searxng"],
aliases: ["se"],
},
{
cmd: "skill",
argsHint: "[list|show <name>|<name> [args]]",
@@ -147,13 +155,17 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
summary: "show semantic_search status — built? Ollama installed? how to enable",
},
{ cmd: "clear", summary: "clear visible scrollback only (log/context kept)" },
{ cmd: "new", summary: "start a fresh conversation (clear context + scrollback)" },
{
cmd: "new",
summary: "start a fresh conversation (clear context + scrollback)",
aliases: ["reset"],
},
{
cmd: "loop",
argsHint: "<5s..6h> <prompt> · stop · (no args = status)",
summary: "auto-resubmit <prompt> every <interval> until you type something / Esc / /loop stop",
},
{ cmd: "exit", summary: "quit the TUI" },
{ cmd: "exit", summary: "quit the TUI", aliases: ["quit", "q"] },
// Code-mode only
{
cmd: "init",
@@ -253,16 +265,31 @@ export function suggestSlashCommands(prefix: string, codeMode = false): SlashCom
const p = prefix.toLowerCase();
return SLASH_COMMANDS.filter((c) => {
if (c.contextual === "code" && !codeMode) return false;
return c.cmd.startsWith(p);
if (c.cmd.startsWith(p)) return true;
return c.aliases?.some((a) => a.startsWith(p)) ?? false;
});
}
/** alias → canonical cmd map, derived from SLASH_COMMANDS at module init. */
const ALIAS_TO_CMD: Readonly<Record<string, string>> = (() => {
const m: Record<string, string> = {};
for (const spec of SLASH_COMMANDS) {
if (!spec.aliases) continue;
for (const a of spec.aliases) m[a] = spec.cmd;
}
return m;
})();
export function resolveSlashAlias(name: string): string {
return ALIAS_TO_CMD[name] ?? name;
}
/** Picker fires only when arg tail has no internal whitespace; past that it's a usage hint. */
export function detectSlashArgContext(input: string, codeMode = false): SlashArgContext | null {
// `/cmd <rest>` — one space, rest captured up to end-of-buffer.
const m = /^\/(\S+) ([\s\S]*)$/.exec(input);
if (!m) return null;
const cmdName = m[1]!.toLowerCase();
const cmdName = resolveSlashAlias(m[1]!.toLowerCase());
const tail = m[2] ?? "";
const spec = SLASH_COMMANDS.find(
(s) => s.cmd === cmdName && (s.contextual !== "code" || codeMode),
+4 -1
View File
@@ -1,4 +1,5 @@
import type { CacheFirstLoop } from "../../../loop.js";
import { resolveSlashAlias } from "./commands.js";
import { handlers as adminHandlers } from "./handlers/admin.js";
import { handlers as basicHandlers } from "./handlers/basic.js";
import { handlers as dashboardHandlers } from "./handlers/dashboard.js";
@@ -15,6 +16,7 @@ import { handlers as plansHandlers } from "./handlers/plans.js";
import { handlers as semanticHandlers } from "./handlers/semantic.js";
import { handlers as sessionsHandlers } from "./handlers/sessions.js";
import { handlers as skillHandlers } from "./handlers/skill.js";
import { handlers as webSearchEngineHandlers } from "./handlers/web-search-engine.js";
import type { SlashContext, SlashResult } from "./types.js";
/** Synchronous return — async work fires-and-forgets via `ctx.postInfo` to keep input non-blocking. */
@@ -37,6 +39,7 @@ const HANDLERS: Record<string, SlashHandler> = {
...semanticHandlers,
...sessionsHandlers,
...skillHandlers,
...webSearchEngineHandlers,
};
export function handleSlash(
@@ -45,7 +48,7 @@ export function handleSlash(
loop: CacheFirstLoop,
ctx: SlashContext = {},
): SlashResult {
const h = HANDLERS[cmd];
const h = HANDLERS[resolveSlashAlias(cmd)];
if (h) return h(args, loop, ctx);
return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
}
-3
View File
@@ -189,13 +189,10 @@ const loop: SlashHandler = (args, _loop, ctx) => {
export const handlers: Record<string, SlashHandler> = {
exit,
quit: exit,
clear,
new: resetLog,
reset: resetLog,
keys,
help,
"?": help,
retry,
loop,
};
-1
View File
@@ -25,5 +25,4 @@ export const handlers: Record<string, SlashHandler> = {
return { info: t("slash.language.success") };
},
lang: (args, loop, ctx) => handlers.language!(args, loop, ctx),
};
@@ -0,0 +1,43 @@
import { readConfig, webSearchEndpoint, webSearchEngine, writeConfig } from "../../../../config.js";
import type { SlashHandler } from "../dispatch.js";
export const handlers: Record<string, SlashHandler> = {
"search-engine": (args, _loop, ctx) => {
const engine = args[0];
if (!engine || (engine !== "mojeek" && engine !== "searxng")) {
return {
info: [
`Current web search engine: ${webSearchEngine()}`,
`SearXNG endpoint: ${webSearchEndpoint()}`,
"",
"Usage:",
" /search-engine mojeek use Mojeek (default, no external deps)",
" /search-engine searxng use SearXNG at default endpoint",
" /search-engine searxng <url> use SearXNG at custom endpoint",
"",
"Alias: /se",
"",
"SearXNG is a self-hosted metasearch engine (https://github.com/searxng/searxng).",
"Install it with: docker run -d -p 8080:8080 searxng/searxng",
].join("\n"),
};
}
const cfg = readConfig();
cfg.webSearchEngine = engine;
if (engine === "searxng" && args[1]) {
const raw = args[1];
cfg.webSearchEndpoint = raw.includes("://") ? raw : `http://${raw}`;
}
writeConfig(cfg);
ctx.postInfo?.(
`Switched web search engine to "${engine}". ${engine === "searxng" ? `Make sure SearXNG is running at ${webSearchEndpoint()}.` : ""}`,
);
return {
info: `✓ Web search engine set to "${engine}"${engine === "searxng" ? ` (${webSearchEndpoint()})` : ""}. Next assistant turn will pick up the change.`,
};
},
se: (args, loop, ctx) => handlers["search-engine"]!(args, loop, ctx),
};
+2
View File
@@ -132,6 +132,8 @@ export interface SlashCommandSpec {
argsHint?: string;
/** First-arg picker source — file paths intentionally absent (use `@path` mentions instead). */
argCompleter?: "models" | "mcp-resources" | "mcp-prompts" | readonly string[];
/** Alternate names — typing any of these resolves to `cmd` for dispatch / suggestion / arg-context. */
aliases?: readonly string[];
}
export interface SlashArgContext {
+32 -52
View File
@@ -1,89 +1,69 @@
import React, { type ReactNode, createContext, useContext, useEffect, useState } from "react";
import { useAnimation } from "ink";
import React, { type ReactNode, createContext, useContext, useState } from "react";
/**
* Two-tier global heartbeat. Both timers are owned by one provider so
* we still pay for a single setInterval per cadence, but components
* subscribe to whichever tier matches what they actually need to
* repaint:
* Two-tier global heartbeat backed by Ink 7's `useAnimation`. The
* provider only stores an `isActive` boolean; the actual frame timer
* lives inside Ink and consolidates with every other useAnimation
* caller into a single shared interval.
*
* - FAST_TICK_MS (120ms) — spinners, glyph pulses, anything that
* visibly animates frame-by-frame.
* - SLOW_TICK_MS (1000ms) — elapsed-seconds counters, expiry
* countdowns, out-of-React mutation pollers (job registries, file
* stat refresh). These don't NEED 8Hz re-renders — they update
* once per visible-state change anyway, so subscribing them to
* the fast tick was 7 wasted re-renders per second per consumer.
* countdowns, polling pollers. Don't need 8Hz re-renders.
*
* Splitting the contexts means a slow consumer (Elapsed timer) no
* longer reconciles when only the fast tick increments — React's
* context-propagation only fires for subscribers of the changed
* context. The streaming row's spinner and elapsed counter used to
* re-render together every 120ms; now the elapsed re-renders at 1Hz
* while the spinner keeps its 8Hz cadence.
* Setting `disabled` flips `isActive` to `false`, which Ink propagates
* to every active animation. Repaints stop entirely until isActive
* flips back, at which point Ink resets the frame counter to 0 (so
* spinners restart from frame 0 — visually identical to a fresh mount).
*/
export const FAST_TICK_MS = 120;
export const SLOW_TICK_MS = 1000;
/** @deprecated kept for callers that import the old name. */
export const TICK_MS = FAST_TICK_MS;
const FastTickContext = createContext(0);
const SlowTickContext = createContext(0);
const TickerActiveContext = createContext(true);
export interface TickerProviderProps {
children: ReactNode;
/**
* When true, the provider skips both setIntervals — ticks stay at 0,
* all consumers render once and never re-render from the timer. Used
* by PLAIN_UI mode so the cursor and any surviving spinners don't drive
* repaints on fragile Windows terminals.
* When true, every tick-driven animation pauses. Used by PLAIN_UI
* mode, modal overlays, and the idle-gate so a quiescent TUI is
* byte-stable (cursor blink and gradient pulses don't re-render).
*/
disabled?: boolean;
}
export function TickerProvider({ children, disabled }: TickerProviderProps) {
const [fast, setFast] = useState(0);
const [slow, setSlow] = useState(0);
useEffect(() => {
if (disabled) return;
const fastId = setInterval(() => setFast((t) => t + 1), FAST_TICK_MS);
const slowId = setInterval(() => setSlow((t) => t + 1), SLOW_TICK_MS);
return () => {
clearInterval(fastId);
clearInterval(slowId);
};
}, [disabled]);
return (
<FastTickContext.Provider value={fast}>
<SlowTickContext.Provider value={slow}>{children}</SlowTickContext.Provider>
</FastTickContext.Provider>
);
return <TickerActiveContext.Provider value={!disabled}>{children}</TickerActiveContext.Provider>;
}
function useTickerActive(): boolean {
return useContext(TickerActiveContext);
}
/**
* Fast tick — re-renders the calling component every {@link FAST_TICK_MS}.
* Use for spinner frames, glyph pulses, and anything else that
* visibly animates. If your component just shows a counter that
* updates once per second, prefer {@link useSlowTick}.
* Fast tick — re-renders the calling component every FAST_TICK_MS
* (120ms). Use for spinner frames, glyph pulses, anything that
* visibly animates frame-by-frame.
*/
export function useTick(): number {
return useContext(FastTickContext);
const isActive = useTickerActive();
return useAnimation({ interval: FAST_TICK_MS, isActive }).frame;
}
/**
* Slow tick — re-renders the calling component every {@link SLOW_TICK_MS}.
* Use for elapsed-seconds counters, expiry countdowns, or pollers
* that just need a "what's the time NOW?" trigger once per second.
* Slow tick — re-renders the calling component every SLOW_TICK_MS
* (1000ms). Use for elapsed-seconds counters, expiry countdowns,
* or pollers that just need a "what's the time NOW?" trigger once
* per second.
*/
export function useSlowTick(): number {
return useContext(SlowTickContext);
const isActive = useTickerActive();
return useAnimation({ interval: SLOW_TICK_MS, isActive }).frame;
}
/**
* Seconds elapsed since the calling component mounted. Subscribes to
* the slow tick — re-renders at 1Hz, which is exactly what a
* second-resolution counter needs. Earlier versions used the fast
* tick and re-rendered 8x/sec just to display the same number.
*/
/** Seconds elapsed since mount. Re-renders at 1Hz via the slow tick. */
export function useElapsedSeconds(): number {
const [start] = useState(() => Date.now());
useSlowTick();
+26 -1
View File
@@ -16,6 +16,8 @@ import {
export interface UndoBannerState {
results: ApplyResult[];
expiresAt: number;
/** Set when the user paused the countdown; banner stays up until they resume or hit `u`. */
pausedRemainingMs: number | null;
}
export interface UseEditHistoryResult {
@@ -30,6 +32,8 @@ export interface UseEditHistoryResult {
) => void;
/** Replaces the dismiss timer so multiple edits in one turn don't prematurely expire the window. */
armUndoBanner: (results: ApplyResult[]) => void;
/** Pause / resume the active undo countdown. No-ops if the banner is already settled. */
toggleUndoPause: () => void;
codeUndo: (args?: readonly string[]) => string;
codeHistory: () => string;
codeShowEdit: (args?: readonly string[]) => string;
@@ -77,7 +81,7 @@ export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEd
);
const armUndoBanner = useCallback<UseEditHistoryResult["armUndoBanner"]>((results) => {
setUndoBanner({ results, expiresAt: Date.now() + 5000 });
setUndoBanner({ results, expiresAt: Date.now() + 5000, pausedRemainingMs: null });
if (undoTimeoutRef.current) clearTimeout(undoTimeoutRef.current);
undoTimeoutRef.current = setTimeout(() => {
setUndoBanner(null);
@@ -85,6 +89,26 @@ export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEd
}, 5000);
}, []);
const toggleUndoPause = useCallback<UseEditHistoryResult["toggleUndoPause"]>(() => {
setUndoBanner((prev) => {
if (!prev) return prev;
if (prev.pausedRemainingMs === null) {
const remaining = Math.max(0, prev.expiresAt - Date.now());
if (undoTimeoutRef.current) {
clearTimeout(undoTimeoutRef.current);
undoTimeoutRef.current = null;
}
return { ...prev, pausedRemainingMs: remaining };
}
const remaining = prev.pausedRemainingMs;
undoTimeoutRef.current = setTimeout(() => {
setUndoBanner(null);
undoTimeoutRef.current = null;
}, remaining);
return { ...prev, expiresAt: Date.now() + remaining, pausedRemainingMs: null };
});
}, []);
const codeUndo = useCallback<UseEditHistoryResult["codeUndo"]>(
(args = []) => {
if (!codeMode) return "not in code mode";
@@ -266,6 +290,7 @@ export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEd
undoBanner,
recordEdit,
armUndoBanner,
toggleUndoPause,
codeUndo,
codeHistory,
codeShowEdit,
+16
View File
@@ -33,6 +33,10 @@ export interface ReasonixConfig {
session?: string | null;
setupCompleted?: boolean;
search?: boolean;
/** Web search engine backend: "mojeek" (default, scrapes Mojeek) or "searxng" (self-hosted SearXNG). */
webSearchEngine?: "mojeek" | "searxng";
/** Base URL for SearXNG instance (default http://localhost:8080). */
webSearchEndpoint?: string;
projects?: {
[absoluteRootDir: string]: {
shellAllowed?: string[];
@@ -93,6 +97,18 @@ export function searchEnabled(path: string = defaultConfigPath()): boolean {
return true;
}
export function webSearchEngine(path: string = defaultConfigPath()): "mojeek" | "searxng" {
const cfg = readConfig(path).webSearchEngine;
if (cfg === "searxng") return "searxng";
return "mojeek";
}
export function webSearchEndpoint(path: string = defaultConfigPath()): string {
const cfg = readConfig(path).webSearchEndpoint;
if (cfg && typeof cfg === "string") return cfg;
return "http://localhost:8080";
}
export function saveApiKey(key: string, path: string = defaultConfigPath()): void {
const cfg = readConfig(path);
cfg.apiKey = key.trim();
+1 -1
View File
@@ -365,7 +365,7 @@ export const EN: TranslationSchema = {
" /clear clear displayed scrollback only (context kept — model still sees it)",
helpLoop:
" /loop <interval> <prompt> auto-resubmit <prompt> every <interval> (5s..6h). /loop stop · type anything to cancel.",
helpExit: " /exit quit",
helpExit: " /exit quit (aliases: /quit, /q)",
helpShellTitle: "Shell shortcut:",
helpShell: " !<cmd> run <cmd> in the sandbox root; output goes into",
helpShellDetail:
+1 -1
View File
@@ -345,7 +345,7 @@ export const zhCN: TranslationSchema = {
helpClear: " /clear 仅清除显示的滚动回放(上下文保留 — 模型仍能看到)",
helpLoop:
" /loop <interval> <prompt> 每 <interval> 自动重新提交 <prompt>(5秒..6小时)。/loop stop · 输入任何内容取消。",
helpExit: " /exit 退出",
helpExit: " /exit 退出(别名:/quit, /q)",
helpShellTitle: "Shell 快捷方式:",
helpShell: " !<cmd> 在沙箱根目录运行 <cmd>;输出进入对话",
helpShellDetail: " 以便模型在下一轮看到。无允许列表限制。",
+1
View File
@@ -117,6 +117,7 @@ export {
formatSearchResults,
htmlToText,
parseMojeekResults,
parseSearxngHtmlResults,
registerWebTools,
webFetch,
webSearch,
+23
View File
@@ -0,0 +1,23 @@
import { type Stats, statSync } from "node:fs";
import type { StdioMcpSpec } from "./spec.js";
const FILESYSTEM_PKG = "@modelcontextprotocol/server-filesystem";
export function preflightStdioSpec(spec: StdioMcpSpec): void {
const pkgIndex = spec.args.indexOf(FILESYSTEM_PKG);
if (pkgIndex < 0) return;
const positional = spec.args.slice(pkgIndex + 1).filter((a) => !a.startsWith("-"));
for (const dir of positional) {
let stat: Stats;
try {
stat = statSync(dir);
} catch {
throw new Error(
`MCP filesystem sandbox '${dir}' does not exist — create it with: mkdir -p '${dir}'`,
);
}
if (!stat.isDirectory()) {
throw new Error(`MCP filesystem sandbox '${dir}' exists but is not a directory`);
}
}
}
+2
View File
@@ -48,6 +48,8 @@ export interface SessionMeta {
turnCount?: number;
/** Absolute path of the workspace root the session was created/used in. */
workspace?: string;
/** Wallet currency at last save — used to format `totalCostUsd` in the picker without re-fetching balance. */
balanceCurrency?: string;
}
export function sessionsDir(): string {
-189
View File
@@ -1,189 +0,0 @@
import type { CharPool } from "../pools/char-pool.js";
import type { HyperlinkPool } from "../pools/hyperlink-pool.js";
import type { StylePool } from "../pools/style-pool.js";
import { type Cell, CellWidth } from "../screen/cell.js";
import { diffEach } from "../screen/diff.js";
import type { Frame } from "./frame.js";
import type { Patch } from "./patch.js";
export interface DiffPools {
readonly char: CharPool;
readonly style: StylePool;
readonly hyperlink: HyperlinkPool;
}
interface CursorState {
x: number;
y: number;
styleId: number;
hyperlinkId: number;
}
export function diffFrames(prev: Frame, next: Frame, pools: DiffPools): Patch[] {
if (prev.viewportWidth !== next.viewportWidth || prev.viewportHeight !== next.viewportHeight) {
return fullReset(next, pools);
}
const out: Patch[] = [];
const cursor: CursorState = {
x: prev.cursor.x,
y: prev.cursor.y,
styleId: pools.style.none,
hyperlinkId: 0,
};
diffEach(prev.screen, next.screen, (x, y, _prevCell, nextCell) => {
moveTo(out, cursor, x, y, next.viewportWidth);
writeCell(out, cursor, nextCell, pools);
return undefined;
});
appendTrailClears(out, cursor, prev, next);
if (next.cursor.x !== cursor.x || next.cursor.y !== cursor.y) {
moveTo(out, cursor, next.cursor.x, next.cursor.y, next.viewportWidth);
}
if (prev.cursor.visible !== next.cursor.visible) {
out.push({ type: "cursorVisible", visible: next.cursor.visible });
}
resetTrailingState(out, cursor, pools);
return out;
}
function fullReset(next: Frame, pools: DiffPools): Patch[] {
const out: Patch[] = [{ type: "clearTerminal" }];
const cursor: CursorState = { x: 0, y: 0, styleId: pools.style.none, hyperlinkId: 0 };
for (let y = 0; y < next.screen.height; y++) {
for (let x = 0; x < next.screen.width; x++) {
const cell = next.screen.cellAt(x, y);
if (!cell) continue;
if (cell.width === CellWidth.SpacerTail) continue;
moveTo(out, cursor, x, y, next.viewportWidth);
writeCell(out, cursor, cell, pools);
}
}
resetTrailingState(out, cursor, pools);
return out;
}
function moveTo(
out: Patch[],
cursor: CursorState,
targetX: number,
targetY: number,
viewportWidth: number,
): void {
if (cursor.x === targetX && cursor.y === targetY) return;
if (cursor.x >= viewportWidth) {
out.push({ type: "carriageReturn" });
cursor.x = 0;
}
if (cursor.y !== targetY) {
const dy = targetY - cursor.y;
out.push({ type: "carriageReturn" });
out.push({ type: "cursorMove", dx: 0, dy });
cursor.x = 0;
cursor.y = targetY;
}
if (cursor.x !== targetX) {
out.push({ type: "cursorMove", dx: targetX - cursor.x, dy: 0 });
cursor.x = targetX;
}
}
function writeCell(
out: Patch[],
cursor: CursorState,
cell: Cell | undefined,
pools: DiffPools,
): void {
if (!cell) {
transitionStyle(out, cursor, pools.style.none, pools);
transitionHyperlink(out, cursor, 0, pools);
out.push({ type: "stdout", content: " " });
cursor.x++;
return;
}
if (cell.width === CellWidth.SpacerTail) return;
transitionStyle(out, cursor, cell.styleId, pools);
transitionHyperlink(out, cursor, cell.hyperlinkId, pools);
const isWide = cell.width === CellWidth.Wide;
const charStr = pools.char.get(cell.charId);
if (isWide) {
// Compensation for terminal-vs-layout width disagreement on East Asian
// Ambiguous chars: pre-write a space at col+1 (so it's blank on Western
// terms where the glyph renders only 1 wide), then jump back, write the
// char, then force the cursor to col+2 regardless of how many cells the
// terminal actually advanced. Borrowed from Claude Code's logUpdate.
out.push({ type: "cursorTo", col: cursor.x + 1 });
out.push({ type: "stdout", content: " " });
out.push({ type: "cursorTo", col: cursor.x });
out.push({ type: "stdout", content: charStr });
cursor.x += 2;
out.push({ type: "cursorTo", col: cursor.x });
} else {
out.push({ type: "stdout", content: charStr });
cursor.x += 1;
}
}
function transitionStyle(
out: Patch[],
cursor: CursorState,
targetStyleId: number,
pools: DiffPools,
): void {
if (cursor.styleId === targetStyleId) return;
const str = pools.style.transition(cursor.styleId, targetStyleId);
if (str.length > 0) out.push({ type: "styleStr", str });
cursor.styleId = targetStyleId;
}
function transitionHyperlink(
out: Patch[],
cursor: CursorState,
targetId: number,
pools: DiffPools,
): void {
if (cursor.hyperlinkId === targetId) return;
out.push({ type: "hyperlink", uri: pools.hyperlink.get(targetId) ?? "" });
cursor.hyperlinkId = targetId;
}
function resetTrailingState(out: Patch[], cursor: CursorState, pools: DiffPools): void {
transitionStyle(out, cursor, pools.style.none, pools);
transitionHyperlink(out, cursor, 0, pools);
}
/** Per-row trail clear when prev had content past next's last visible cell — defends shrinking rows against terminal-state desync. */
function appendTrailClears(out: Patch[], cursor: CursorState, prev: Frame, next: Frame): void {
const w = Math.min(prev.screen.width, next.viewportWidth);
const h = Math.max(prev.screen.height, next.screen.height);
for (let y = 0; y < h; y++) {
let lastNextCol = -1;
for (let x = next.screen.width - 1; x >= 0; x--) {
const c = next.screen.cellAt(x, y);
if (c && c.charId !== 0) {
lastNextCol = x;
break;
}
}
let prevHasTrail = false;
for (let x = lastNextCol + 1; x < w; x++) {
const c = prev.screen.cellAt(x, y);
if (c && c.charId !== 0) {
prevHasTrail = true;
break;
}
}
if (!prevHasTrail) continue;
moveTo(out, cursor, Math.max(0, lastNextCol + 1), y, next.viewportWidth);
out.push({ type: "clearToEOL" });
}
}
-23
View File
@@ -1,23 +0,0 @@
import { Screen } from "../screen/screen.js";
export interface Cursor {
readonly x: number;
readonly y: number;
readonly visible: boolean;
}
export interface Frame {
readonly screen: Screen;
readonly viewportWidth: number;
readonly viewportHeight: number;
readonly cursor: Cursor;
}
export function emptyFrame(viewportWidth: number, viewportHeight: number): Frame {
return {
screen: new Screen(0, 0),
viewportWidth,
viewportHeight,
cursor: { x: 0, y: 0, visible: true },
};
}
-13
View File
@@ -1,13 +0,0 @@
export type Patch =
| { readonly type: "stdout"; readonly content: string }
| { readonly type: "cursorMove"; readonly dx: number; readonly dy: number }
| { readonly type: "cursorTo"; readonly col: number }
| { readonly type: "cursorVisible"; readonly visible: boolean }
| { readonly type: "carriageReturn" }
| { readonly type: "styleStr"; readonly str: string }
| { readonly type: "hyperlink"; readonly uri: string }
| { readonly type: "clear"; readonly count: number }
| { readonly type: "clearToEOL" }
| { readonly type: "clearTerminal" };
export type Diff = ReadonlyArray<Patch>;
-52
View File
@@ -1,52 +0,0 @@
import type { Diff, Patch } from "./patch.js";
const ESC = "\x1b";
const CSI = `${ESC}[`;
const ST = `${ESC}\\`;
export function serializePatches(patches: Diff): string {
let out = "";
for (const patch of patches) {
out += serializeOne(patch);
}
return out;
}
function serializeOne(patch: Patch): string {
switch (patch.type) {
case "stdout":
return patch.content;
case "cursorMove":
return cursorMove(patch.dx, patch.dy);
case "cursorTo":
return `${CSI}${patch.col + 1}G`;
case "cursorVisible":
return patch.visible ? `${CSI}?25h` : `${CSI}?25l`;
case "carriageReturn":
return "\r";
case "styleStr":
return patch.str;
case "hyperlink":
return `${ESC}]8;;${patch.uri}${ST}`;
case "clear":
return clearLines(patch.count);
case "clearToEOL":
return `${CSI}K`;
case "clearTerminal":
return `${CSI}2J${CSI}H`;
}
}
function cursorMove(dx: number, dy: number): string {
let out = "";
if (dy > 0) out += `${CSI}${dy}B`;
else if (dy < 0) out += `${CSI}${-dy}A`;
if (dx > 0) out += `${CSI}${dx}C`;
else if (dx < 0) out += `${CSI}${-dx}D`;
return out;
}
function clearLines(count: number): string {
if (count <= 0) return `\r${CSI}J`;
return `\r${CSI}${count}A${CSI}J`;
}
-37
View File
@@ -1,37 +0,0 @@
export { CharPool } from "./pools/char-pool.js";
export { HyperlinkPool } from "./pools/hyperlink-pool.js";
export { StylePool, type AnsiCode } from "./pools/style-pool.js";
export { type Cell, CellWidth, EMPTY_CELL, cellsEqual } from "./screen/cell.js";
export { type Rectangle, Screen } from "./screen/screen.js";
export { type DiffCallback, diffEach } from "./screen/diff.js";
export type { BorderStyle, BorderStyleName } from "./layout/borders.js";
export type { BoxNode, LayoutNode, TextNode } from "./layout/node.js";
export { type RenderPools, renderToScreen } from "./layout/layout.js";
export { Box, type BoxProps, Text, type TextProps } from "./react/components.js";
export { type RenderOptions, render } from "./react/render.js";
export { type Cursor, type Frame, emptyFrame } from "./diff/frame.js";
export type { Diff, Patch } from "./diff/patch.js";
export { type DiffPools, diffFrames } from "./diff/diff-frames.js";
export { serializePatches } from "./diff/serialize.js";
export { Renderer, type RendererOptions } from "./runtime/renderer.js";
export { renderToBytes } from "./runtime/render-to-bytes.js";
export { type TestWriter, makeTestWriter } from "./runtime/test-writer.js";
export { type Handle, type MountOptions, mount } from "./reconciler/mount.js";
export {
CursorContext,
type CursorSetter,
type CursorTarget,
useCursor,
} from "./reconciler/cursor.js";
export {
type Keystroke,
KeystrokeContext,
KeystrokeReader,
type KeystrokeListener,
type KeystrokeReaderOptions,
type KeystrokeSource,
emptyKeystroke,
parseKeystrokes,
useKeystroke,
} from "./input/index.js";
export * as inkCompat from "./ink-compat/index.js";
-110
View File
@@ -1,110 +0,0 @@
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { type ReactNode } from "react";
import type { BorderStyle, BorderStyleName } from "../layout/borders.js";
import type { AlignItems, FlexWrap, JustifyContent } from "../layout/node.js";
import { Box as RsxBox } from "../react/components.js";
import { fgCode } from "./colors.js";
export interface InkBoxProps {
readonly children?: ReactNode;
readonly flexDirection?: "column" | "row" | "column-reverse" | "row-reverse";
readonly flexGrow?: number;
readonly flexShrink?: number;
readonly flexBasis?: number | `${number}%` | "auto";
readonly flexWrap?: FlexWrap;
readonly padding?: number;
readonly paddingX?: number;
readonly paddingY?: number;
readonly paddingTop?: number;
readonly paddingBottom?: number;
readonly paddingLeft?: number;
readonly paddingRight?: number;
readonly margin?: number;
readonly marginX?: number;
readonly marginY?: number;
readonly marginTop?: number;
readonly marginBottom?: number;
readonly marginLeft?: number;
readonly marginRight?: number;
readonly borderStyle?: BorderStyle | BorderStyleName;
readonly borderColor?: string;
readonly borderTopColor?: string;
readonly borderBottomColor?: string;
readonly borderLeftColor?: string;
readonly borderRightColor?: string;
readonly borderTop?: boolean;
readonly borderBottom?: boolean;
readonly borderLeft?: boolean;
readonly borderRight?: boolean;
readonly width?: number | `${number}%`;
readonly height?: number | `${number}%`;
readonly minWidth?: number | `${number}%`;
readonly minHeight?: number | `${number}%`;
readonly maxWidth?: number | `${number}%`;
readonly maxHeight?: number | `${number}%`;
readonly justifyContent?: JustifyContent;
readonly alignItems?: AlignItems;
readonly alignSelf?: AlignItems;
readonly alignContent?: AlignItems | "space-between" | "space-around";
readonly gap?: number;
readonly columnGap?: number;
readonly rowGap?: number;
readonly display?: "flex" | "none";
}
export function Box(props: InkBoxProps): React.ReactElement {
const borderColor = fgCode(props.borderColor);
const borderTopColor = fgCode(props.borderTopColor);
const borderBottomColor = fgCode(props.borderBottomColor);
const borderLeftColor = fgCode(props.borderLeftColor);
const borderRightColor = fgCode(props.borderRightColor);
return (
<RsxBox
flexDirection={props.flexDirection ?? "row"}
flexGrow={props.flexGrow}
flexShrink={props.flexShrink}
flexBasis={props.flexBasis}
flexWrap={props.flexWrap}
justifyContent={props.justifyContent}
alignItems={props.alignItems}
alignSelf={props.alignSelf}
alignContent={props.alignContent}
width={props.width}
height={props.height}
minWidth={props.minWidth}
minHeight={props.minHeight}
maxWidth={props.maxWidth}
maxHeight={props.maxHeight}
gap={props.gap}
columnGap={props.columnGap}
rowGap={props.rowGap}
display={props.display}
padding={props.padding}
paddingX={props.paddingX}
paddingY={props.paddingY}
paddingTop={props.paddingTop}
paddingBottom={props.paddingBottom}
paddingLeft={props.paddingLeft}
paddingRight={props.paddingRight}
margin={props.margin}
marginX={props.marginX}
marginY={props.marginY}
marginTop={props.marginTop}
marginBottom={props.marginBottom}
marginLeft={props.marginLeft}
marginRight={props.marginRight}
borderStyle={props.borderStyle}
borderColor={borderColor ? [borderColor] : undefined}
borderTopColor={borderTopColor ? [borderTopColor] : undefined}
borderBottomColor={borderBottomColor ? [borderBottomColor] : undefined}
borderLeftColor={borderLeftColor ? [borderLeftColor] : undefined}
borderRightColor={borderRightColor ? [borderRightColor] : undefined}
borderTop={props.borderTop}
borderBottom={props.borderBottom}
borderLeft={props.borderLeft}
borderRight={props.borderRight}
>
{props.children}
</RsxBox>
);
}
-7
View File
@@ -1,7 +0,0 @@
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { Box } from "../react/components.js";
export function Spacer(): React.ReactElement {
return <Box flexGrow={1} />;
}
-46
View File
@@ -1,46 +0,0 @@
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { type ReactNode, useContext, useEffect, useRef } from "react";
import { Box } from "../react/components.js";
import { RendererBridgeContext } from "./renderer-bridge.js";
export interface StaticProps<T> {
readonly items: ReadonlyArray<T>;
readonly children: (item: T, index: number) => ReactNode;
}
export function Static<T>(props: StaticProps<T>): React.ReactElement | null {
const bridge = useContext(RendererBridgeContext);
const lastIndex = useRef(0);
useEffect(() => {
if (!bridge) return;
if (props.items.length < lastIndex.current) {
lastIndex.current = props.items.length;
return;
}
if (props.items.length === lastIndex.current) return;
const fresh = props.items.slice(lastIndex.current);
const startIndex = lastIndex.current;
lastIndex.current = props.items.length;
bridge.emitStatic(
<Box flexDirection="column">
{fresh.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Static items are positional and append-only
<React.Fragment key={`s-${startIndex + i}`}>
{props.children(item, startIndex + i)}
</React.Fragment>
))}
</Box>,
);
}, [bridge, props.items, props.children]);
if (bridge) return null;
return (
<Box flexDirection="column">
{props.items.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fallback path with no bridge — positional keys are fine
<React.Fragment key={`s-${i}`}>{props.children(item, i)}</React.Fragment>
))}
</Box>
);
}
-48
View File
@@ -1,48 +0,0 @@
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { type ReactNode } from "react";
import type { AnsiCode } from "../pools/style-pool.js";
import { Text as RsxText } from "../react/components.js";
import { bgCode, fgCode } from "./colors.js";
export interface InkTextProps {
readonly children?: ReactNode;
readonly color?: string;
readonly backgroundColor?: string;
readonly bold?: boolean;
readonly dimColor?: boolean;
readonly italic?: boolean;
readonly underline?: boolean;
readonly strikethrough?: boolean;
readonly inverse?: boolean;
readonly wrap?: "wrap" | "truncate" | "truncate-start" | "truncate-middle" | "truncate-end";
/** OSC 8 hyperlink target — emitted as proper escape bytes, never inlined into cell content. */
readonly hyperlink?: string;
}
const SGR = {
bold: { apply: "\x1b[1m", revert: "\x1b[22m" },
dim: { apply: "\x1b[2m", revert: "\x1b[22m" },
italic: { apply: "\x1b[3m", revert: "\x1b[23m" },
underline: { apply: "\x1b[4m", revert: "\x1b[24m" },
inverse: { apply: "\x1b[7m", revert: "\x1b[27m" },
strikethrough: { apply: "\x1b[9m", revert: "\x1b[29m" },
} as const;
export function Text(props: InkTextProps): React.ReactElement {
const codes: AnsiCode[] = [];
const fg = fgCode(props.color);
if (fg) codes.push(fg);
const bg = bgCode(props.backgroundColor);
if (bg) codes.push(bg);
if (props.bold) codes.push(SGR.bold);
if (props.dimColor) codes.push(SGR.dim);
if (props.italic) codes.push(SGR.italic);
if (props.underline) codes.push(SGR.underline);
if (props.inverse) codes.push(SGR.inverse);
if (props.strikethrough) codes.push(SGR.strikethrough);
return (
<RsxText style={codes.length > 0 ? codes : undefined} hyperlink={props.hyperlink}>
{props.children}
</RsxText>
);
}
-87
View File
@@ -1,87 +0,0 @@
import type { AnsiCode } from "../pools/style-pool.js";
export type ColorName =
| "black"
| "red"
| "green"
| "yellow"
| "blue"
| "magenta"
| "cyan"
| "white"
| "gray"
| "grey"
| "blackBright"
| "redBright"
| "greenBright"
| "yellowBright"
| "blueBright"
| "magentaBright"
| "cyanBright"
| "whiteBright";
const FG_BASE: Record<ColorName, number> = {
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
gray: 90,
grey: 90,
blackBright: 90,
redBright: 91,
greenBright: 92,
yellowBright: 93,
blueBright: 94,
magentaBright: 95,
cyanBright: 96,
whiteBright: 97,
};
const FG_RESET = 39;
const BG_RESET = 49;
export function fgCode(color: string | undefined): AnsiCode | null {
if (!color) return null;
if (color.startsWith("#")) {
const rgb = parseHex(color);
if (!rgb) return null;
return { apply: `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`, revert: `\x1b[${FG_RESET}m` };
}
const code = FG_BASE[color as ColorName];
if (code === undefined) return null;
return { apply: `\x1b[${code}m`, revert: `\x1b[${FG_RESET}m` };
}
export function bgCode(color: string | undefined): AnsiCode | null {
if (!color) return null;
if (color.startsWith("#")) {
const rgb = parseHex(color);
if (!rgb) return null;
return { apply: `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`, revert: `\x1b[${BG_RESET}m` };
}
const base = FG_BASE[color as ColorName];
if (base === undefined) return null;
const bg = base < 90 ? base + 10 : base + 10;
return { apply: `\x1b[${bg}m`, revert: `\x1b[${BG_RESET}m` };
}
function parseHex(hex: string): [number, number, number] | null {
const s = hex.slice(1);
if (s.length === 3) {
const r = Number.parseInt(s[0]! + s[0]!, 16);
const g = Number.parseInt(s[1]! + s[1]!, 16);
const b = Number.parseInt(s[2]! + s[2]!, 16);
return Number.isNaN(r) ? null : [r, g, b];
}
if (s.length === 6) {
const r = Number.parseInt(s.slice(0, 2), 16);
const g = Number.parseInt(s.slice(2, 4), 16);
const b = Number.parseInt(s.slice(4, 6), 16);
return Number.isNaN(r) ? null : [r, g, b];
}
return null;
}
-16
View File
@@ -1,16 +0,0 @@
export { Box, type InkBoxProps } from "./Box.js";
export { Text, type InkTextProps } from "./Text.js";
export { Spacer } from "./Spacer.js";
export { Static, type StaticProps } from "./Static.js";
export { type RendererBridge, RendererBridgeContext } from "./renderer-bridge.js";
export { type ColorName, bgCode, fgCode } from "./colors.js";
export {
type ViewportSize,
StdoutContext,
StdoutProvider,
ViewportContext,
useStdout,
} from "./viewport.js";
export { type AppApi, AppContext, useApp } from "./use-app.js";
export { type InkInputHandler, type InkKey, type UseInputOptions, useInput } from "./use-input.js";
export { type InkLikeInstance, type InkLikeRenderOptions, render } from "./render.js";
-116
View File
@@ -1,116 +0,0 @@
/** Ink-shaped render() that mounts a React tree on the cell-diff renderer. */
import type { ReactNode } from "react";
import { CharPool } from "../pools/char-pool.js";
import { HyperlinkPool } from "../pools/hyperlink-pool.js";
import { StylePool } from "../pools/style-pool.js";
import { type Handle, mount } from "../reconciler/mount.js";
export interface InkLikeRenderOptions {
readonly stdout?: NodeJS.WriteStream;
readonly stdin?: NodeJS.ReadStream;
readonly stderr?: NodeJS.WriteStream;
/** Default true. Listens for Ctrl+C on stdin and unmounts. */
readonly exitOnCtrlC?: boolean;
/** Ink had this; we don't patch console, so this option is accepted but ignored. */
readonly patchConsole?: boolean;
}
export interface InkLikeInstance {
rerender(element: ReactNode): void;
unmount(): void;
waitUntilExit(): Promise<void>;
cleanup(): void;
clear(): void;
}
export function render(element: ReactNode, opts: InkLikeRenderOptions = {}): InkLikeInstance {
const stdout = opts.stdout ?? process.stdout;
const stdin = opts.stdin ?? process.stdin;
const exitOnCtrlC = opts.exitOnCtrlC !== false;
const pools = {
char: new CharPool(),
style: new StylePool(),
hyperlink: new HyperlinkPool(),
};
let resolveExit: () => void = () => {};
let exitError: Error | undefined;
const exitPromise = new Promise<void>((resolve, reject) => {
resolveExit = () => {
if (exitError) reject(exitError);
else resolve();
};
});
let destroyed = false;
let handle: Handle | null = null;
const onResize = (): void => {
if (destroyed || !handle) return;
handle.resize(stdout.columns ?? 80, stdout.rows ?? 24);
};
const onCtrlCData = (chunk: Buffer | string): void => {
if (destroyed || !exitOnCtrlC) return;
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
// Ctrl+C in raw mode is a literal \x03; outside raw mode the terminal turns
// it into SIGINT and we never see it here. Either path lands in unmount.
if (text.includes("\x03")) instance.unmount();
};
handle = mount(element, {
viewportWidth: stdout.columns ?? 80,
viewportHeight: stdout.rows ?? 24,
pools,
write: (bytes) => stdout.write(bytes),
stdin,
stdout,
onExit: (err) => {
if (destroyed) return;
if (err) exitError = err;
// Tear down on app-driven exit (useApp().exit()).
instance.unmount();
},
});
stdout.on("resize", onResize);
stdin.on("data", onCtrlCData);
const sigintListener = (): void => {
if (!destroyed) instance.unmount();
};
if (exitOnCtrlC) process.on("SIGINT", sigintListener);
const instance: InkLikeInstance = {
rerender(next) {
if (destroyed || !handle) return;
handle.update(next);
},
unmount() {
if (destroyed) return;
destroyed = true;
stdout.off("resize", onResize);
stdin.off("data", onCtrlCData);
if (exitOnCtrlC) process.off("SIGINT", sigintListener);
handle?.destroy();
handle = null;
resolveExit();
},
waitUntilExit() {
return exitPromise;
},
cleanup() {
// Ink uses cleanup() to clear listeners without unmounting; for our renderer
// the listeners are bound to the handle's lifetime, so this is a no-op
// unless explicitly unmounting.
},
clear() {
if (destroyed) return;
stdout.write("\x1b[2J\x1b[H");
},
};
return instance;
}
@@ -1,7 +0,0 @@
import { type ReactNode, createContext } from "react";
export interface RendererBridge {
readonly emitStatic: (element: ReactNode) => void;
}
export const RendererBridgeContext = createContext<RendererBridge | null>(null);
-11
View File
@@ -1,11 +0,0 @@
import { createContext, useContext } from "react";
export interface AppApi {
readonly exit: (error?: Error) => void;
}
export const AppContext = createContext<AppApi>({ exit: () => {} });
export function useApp(): AppApi {
return useContext(AppContext);
}
-51
View File
@@ -1,51 +0,0 @@
import { type Keystroke, useKeystroke } from "../input/index.js";
export interface InkKey {
readonly upArrow: boolean;
readonly downArrow: boolean;
readonly leftArrow: boolean;
readonly rightArrow: boolean;
readonly pageDown: boolean;
readonly pageUp: boolean;
readonly return: boolean;
readonly escape: boolean;
readonly ctrl: boolean;
readonly shift: boolean;
readonly tab: boolean;
readonly backspace: boolean;
readonly delete: boolean;
readonly meta: boolean;
}
export type InkInputHandler = (input: string, key: InkKey) => void;
export interface UseInputOptions {
readonly isActive?: boolean;
}
export function useInput(handler: InkInputHandler, options: UseInputOptions = {}): void {
const active = options.isActive !== false;
useKeystroke((k: Keystroke) => {
if (!active) return;
handler(k.input ?? "", toInkKey(k));
});
}
function toInkKey(k: Keystroke): InkKey {
return {
upArrow: k.upArrow,
downArrow: k.downArrow,
leftArrow: k.leftArrow,
rightArrow: k.rightArrow,
pageDown: k.pageDown,
pageUp: k.pageUp,
return: k.return,
escape: k.escape,
ctrl: k.ctrl,
shift: k.shift,
tab: k.tab,
backspace: k.backspace,
delete: k.delete,
meta: k.meta,
};
}
-59
View File
@@ -1,59 +0,0 @@
/** Mirrors Ink's useStdout — wires viewport sizes + real-stdout methods. */
import { type ReactNode, createContext, createElement, useContext, useMemo } from "react";
export interface ViewportSize {
readonly columns: number;
readonly rows: number;
}
export const ViewportContext = createContext<ViewportSize>({ columns: 80, rows: 24 });
export const StdoutContext = createContext<NodeJS.WriteStream>(
(typeof process !== "undefined" ? (process.stdout ?? null) : null) as NodeJS.WriteStream,
);
export function StdoutProvider({
stdout,
children,
}: {
stdout: NodeJS.WriteStream;
children: ReactNode;
}): ReturnType<typeof createElement> {
return createElement(StdoutContext.Provider, { value: stdout }, children);
}
/** Quacks like Ink's `useStdout().stdout`: viewport-driven `columns`/`rows`
* + real `.on/.off/.write/.isTTY` proxied to the WriteStream. */
export interface InkStdout {
readonly columns: number;
readonly rows: number;
readonly isTTY: boolean | undefined;
write(data: string): boolean;
on(event: string, listener: (...args: unknown[]) => void): unknown;
off(event: string, listener: (...args: unknown[]) => void): unknown;
}
export function useStdout(): { stdout: InkStdout; write: (data: string) => boolean } {
const viewport = useContext(ViewportContext);
const real = useContext(StdoutContext);
// Stable identity: viewport-budget's `useEffect([stdout])` would otherwise
// re-fire every render (the original cause of the modal-mount flicker).
return useMemo(() => {
const stdout: InkStdout = {
columns: viewport.columns,
rows: viewport.rows,
isTTY: real ? real.isTTY : undefined,
write: real ? (data: string) => real.write(data) : () => true,
on: real
? (event: string, listener: (...args: unknown[]) => void) =>
real.on(event as never, listener as never)
: () => undefined,
off: real
? (event: string, listener: (...args: unknown[]) => void) =>
real.off(event as never, listener as never)
: () => undefined,
};
return { stdout, write: stdout.write };
}, [viewport.columns, viewport.rows, real]);
}
-12
View File
@@ -1,12 +0,0 @@
export {
type Keystroke,
emptyKeystroke,
parseKeystrokes,
} from "./keystroke.js";
export {
KeystrokeReader,
type KeystrokeReaderOptions,
type KeystrokeListener,
type KeystrokeSource,
} from "./reader.js";
export { KeystrokeContext, useKeystroke } from "./use-keystroke.js";
-234
View File
@@ -1,234 +0,0 @@
export interface Keystroke {
readonly input: string;
readonly raw: string;
readonly ctrl: boolean;
readonly meta: boolean;
readonly shift: boolean;
readonly upArrow: boolean;
readonly downArrow: boolean;
readonly leftArrow: boolean;
readonly rightArrow: boolean;
readonly home: boolean;
readonly end: boolean;
readonly pageUp: boolean;
readonly pageDown: boolean;
readonly delete: boolean;
readonly backspace: boolean;
readonly return: boolean;
readonly escape: boolean;
readonly tab: boolean;
readonly wheelUp: boolean;
readonly wheelDown: boolean;
}
export function emptyKeystroke(): Keystroke {
return {
input: "",
raw: "",
ctrl: false,
meta: false,
shift: false,
upArrow: false,
downArrow: false,
leftArrow: false,
rightArrow: false,
home: false,
end: false,
pageUp: false,
pageDown: false,
delete: false,
backspace: false,
return: false,
escape: false,
tab: false,
wheelUp: false,
wheelDown: false,
};
}
const CSI_FINAL: Record<string, Partial<Keystroke>> = {
A: { upArrow: true },
B: { downArrow: true },
C: { rightArrow: true },
D: { leftArrow: true },
H: { home: true },
F: { end: true },
};
const CSI_TILDE: Record<string, Partial<Keystroke>> = {
"1": { home: true },
"3": { delete: true },
"4": { end: true },
"5": { pageUp: true },
"6": { pageDown: true },
"7": { home: true },
"8": { end: true },
};
export function parseKeystrokes(chunk: string): Keystroke[] {
const out: Keystroke[] = [];
let i = 0;
while (i < chunk.length) {
const consumed = parseOne(chunk, i);
if (consumed.length === 0) {
i++;
continue;
}
out.push(consumed.key);
i += consumed.length;
}
return out;
}
interface Consumed {
key: Keystroke;
length: number;
}
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
function parseOne(s: string, start: number): Consumed {
const ch = s[start]!;
// Bracketed-paste: terminal wraps pasted content in ESC[200~ ... ESC[201~.
// Surface the entire payload as ONE keystroke so multi-line reducers can
// recognise it as a paste burst and allocate a single sentinel.
if (ch === "\x1b" && s.startsWith(PASTE_START, start)) {
const bodyStart = start + PASTE_START.length;
const endIdx = s.indexOf(PASTE_END, bodyStart);
if (endIdx >= 0) {
const content = s.slice(bodyStart, endIdx);
return {
key: {
...emptyKeystroke(),
raw: s.slice(start, endIdx + PASTE_END.length),
input: content,
},
length: endIdx + PASTE_END.length - start,
};
}
// Unterminated paste in this chunk — fall through to generic escape
// parsing. Real terminals send the wrapper atomically; if it splits we
// lose the marker but recover the content as normal keystrokes.
}
if (ch === "\x1b") {
return parseEscape(s, start);
}
if (ch === "\r" || ch === "\n") {
return { key: { ...emptyKeystroke(), raw: ch, return: true }, length: 1 };
}
if (ch === "\t") {
return { key: { ...emptyKeystroke(), raw: ch, tab: true }, length: 1 };
}
if (ch === "\x7f" || ch === "\x08") {
return { key: { ...emptyKeystroke(), raw: ch, backspace: true }, length: 1 };
}
const code = ch.charCodeAt(0);
if (code >= 1 && code <= 26 && ch !== "\t" && ch !== "\r" && ch !== "\n") {
const letter = String.fromCharCode(code + 96);
return {
key: { ...emptyKeystroke(), raw: ch, ctrl: true, input: letter },
length: 1,
};
}
return { key: { ...emptyKeystroke(), raw: ch, input: ch }, length: 1 };
}
function parseEscape(s: string, start: number): Consumed {
if (start + 1 >= s.length) {
return { key: { ...emptyKeystroke(), raw: "\x1b", escape: true }, length: 1 };
}
const next = s[start + 1]!;
if (next === "[" || next === "O") {
return parseCsi(s, start);
}
// Alt+Enter — terminals send \x1b\r (or \x1b\n on some). Surface as
// meta+return so multi-line reducers can distinguish it from a bare Enter.
if (next === "\r" || next === "\n") {
return {
key: { ...emptyKeystroke(), raw: `\x1b${next}`, meta: true, return: true },
length: 2,
};
}
if (isPrintable(next)) {
return {
key: { ...emptyKeystroke(), raw: `\x1b${next}`, meta: true, input: next },
length: 2,
};
}
return { key: { ...emptyKeystroke(), raw: "\x1b", escape: true }, length: 1 };
}
function parseCsi(s: string, start: number): Consumed {
let i = start + 2;
let params = "";
while (i < s.length) {
const c = s[i]!;
if (isCsiFinal(c)) {
const raw = s.slice(start, i + 1);
const key = decodeCsi(params, c, raw);
return { key, length: i - start + 1 };
}
params += c;
i++;
}
return {
key: { ...emptyKeystroke(), raw: s.slice(start), escape: true },
length: s.length - start,
};
}
function decodeCsi(params: string, final: string, raw: string): Keystroke {
const base = { ...emptyKeystroke(), raw };
if (params.startsWith("<") && (final === "M" || final === "m")) {
const mouse = params.slice(1).split(";");
const button = Number.parseInt(mouse[0] ?? "", 10);
if (button === 64) return { ...base, wheelUp: true };
if (button === 65) return { ...base, wheelDown: true };
return base;
}
const segments = params.split(";");
const modCode = segments[1] ? Number.parseInt(segments[1], 10) : 1;
const mods = decodeModifiers(modCode);
if (final === "~" && segments[0]) {
const part = CSI_TILDE[segments[0]];
if (part) return { ...base, ...part, ...mods };
}
const part = CSI_FINAL[final];
if (part) return { ...base, ...part, ...mods };
return { ...base, escape: true };
}
function decodeModifiers(code: number): Pick<Keystroke, "shift" | "meta" | "ctrl"> {
const m = code - 1;
return {
shift: (m & 1) !== 0,
meta: (m & 2) !== 0,
ctrl: (m & 4) !== 0,
};
}
function isCsiFinal(ch: string): boolean {
const code = ch.charCodeAt(0);
return code >= 0x40 && code <= 0x7e && ch !== "[";
}
function isPrintable(ch: string): boolean {
const code = ch.charCodeAt(0);
return code >= 0x20 && code <= 0x7e;
}
-59
View File
@@ -1,59 +0,0 @@
import type { Keystroke } from "./keystroke.js";
import { parseKeystrokes } from "./keystroke.js";
export type KeystrokeListener = (key: Keystroke) => void;
export interface KeystrokeSource {
on(event: "data", cb: (chunk: Buffer | string) => void): void;
off(event: "data", cb: (chunk: Buffer | string) => void): void;
setRawMode?(raw: boolean): void;
resume?(): void;
pause?(): void;
}
export interface KeystrokeReaderOptions {
source: KeystrokeSource;
rawMode?: boolean;
}
export class KeystrokeReader {
private listeners: KeystrokeListener[] = [];
private readonly onData: (chunk: Buffer | string) => void;
private destroyed = false;
constructor(private readonly opts: KeystrokeReaderOptions) {
this.onData = (chunk) => this.handle(chunk);
if (opts.rawMode !== false) {
opts.source.setRawMode?.(true);
}
opts.source.resume?.();
opts.source.on("data", this.onData);
}
subscribe(listener: KeystrokeListener): () => void {
this.listeners.push(listener);
return () => {
const i = this.listeners.indexOf(listener);
if (i >= 0) this.listeners.splice(i, 1);
};
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.opts.source.off("data", this.onData);
if (this.opts.rawMode !== false) {
this.opts.source.setRawMode?.(false);
}
this.listeners.length = 0;
}
private handle(chunk: Buffer | string): void {
if (this.destroyed) return;
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
const keys = parseKeystrokes(text);
for (const key of keys) {
for (const cb of this.listeners) cb(key);
}
}
}
-14
View File
@@ -1,14 +0,0 @@
import { createContext, useContext, useEffect } from "react";
import type { Keystroke } from "./keystroke.js";
import type { KeystrokeListener, KeystrokeReader } from "./reader.js";
export const KeystrokeContext = createContext<KeystrokeReader | null>(null);
export function useKeystroke(handler: (key: Keystroke) => void, enabled = true): void {
const reader = useContext(KeystrokeContext);
useEffect(() => {
if (!enabled || !reader) return;
const wrapped: KeystrokeListener = (key) => handler(key);
return reader.subscribe(wrapped);
}, [reader, handler, enabled]);
}
-73
View File
@@ -1,73 +0,0 @@
export interface BorderStyle {
readonly topLeft: string;
readonly top: string;
readonly topRight: string;
readonly left: string;
readonly right: string;
readonly bottomLeft: string;
readonly bottom: string;
readonly bottomRight: string;
}
export type BorderStyleName = "single" | "double" | "round" | "bold" | "ascii";
const BORDER_PRESETS: Record<BorderStyleName, BorderStyle> = {
single: {
topLeft: "┌",
top: "─",
topRight: "┐",
left: "│",
right: "│",
bottomLeft: "└",
bottom: "─",
bottomRight: "┘",
},
double: {
topLeft: "╔",
top: "═",
topRight: "╗",
left: "║",
right: "║",
bottomLeft: "╚",
bottom: "═",
bottomRight: "╝",
},
round: {
topLeft: "╭",
top: "─",
topRight: "╮",
left: "│",
right: "│",
bottomLeft: "╰",
bottom: "─",
bottomRight: "╯",
},
bold: {
topLeft: "┏",
top: "━",
topRight: "┓",
left: "┃",
right: "┃",
bottomLeft: "┗",
bottom: "━",
bottomRight: "┛",
},
ascii: {
topLeft: "+",
top: "-",
topRight: "+",
left: "|",
right: "|",
bottomLeft: "+",
bottom: "-",
bottomRight: "+",
},
};
export function resolveBorderStyle(
style: BorderStyle | BorderStyleName | undefined,
): BorderStyle | undefined {
if (style === undefined) return undefined;
if (typeof style === "string") return BORDER_PRESETS[style];
return style;
}
-468
View File
@@ -1,468 +0,0 @@
import stringWidthRaw from "string-width";
// Box drawing (U+2500..U+257F) and Block Elements (U+2580..U+259F) render as
// width 1 on every modern terminal — pin both explicitly so the CJK-aware
// `ambiguousIsNarrow:false` mode doesn't double-count borders or block art.
function stringWidth(s: string): number {
if (s.length === 0) return 0;
if (s.length === 1) {
const code = s.charCodeAt(0);
if (code >= 0x2500 && code <= 0x259f) return 1;
}
return stringWidthRaw(s, { ambiguousIsNarrow: false });
}
import type { Node as YogaNode } from "yoga-layout";
import type { CharPool } from "../pools/char-pool.js";
import type { HyperlinkPool } from "../pools/hyperlink-pool.js";
import type { AnsiCode, StylePool } from "../pools/style-pool.js";
import { type Cell, CellWidth } from "../screen/cell.js";
import { Screen } from "../screen/screen.js";
import type { BorderStyle } from "./borders.js";
import type { BoxNode, LayoutNode, TextNode } from "./node.js";
import {
type BuiltTree,
Direction,
MeasureMode,
type NodeMeta,
buildYogaTree,
} from "./yoga-layout.js";
export interface RenderPools {
readonly char: CharPool;
readonly style: StylePool;
readonly hyperlink: HyperlinkPool;
}
interface RowFragment {
readonly graphemes: ReadonlyArray<{ glyph: string; width: number }>;
readonly styleId: number;
readonly hyperlinkId: number;
readonly leftPad: number;
}
type LayoutRows = RowFragment[][];
interface Laid {
rows: LayoutRows;
width: number;
}
export function renderToScreen(
node: LayoutNode,
width: number,
pools: RenderPools,
maxHeight?: number,
): Screen {
const w = Math.max(0, width | 0);
if (w === 0) return new Screen(0, 0);
const laid = layout(node, w, pools);
const cap = maxHeight !== undefined ? Math.max(0, Math.floor(maxHeight)) : laid.rows.length;
const skip = Math.max(0, laid.rows.length - cap);
return blitRowSlice(laid.rows, skip, laid.rows.length, w, pools);
}
export interface ViewportSplit {
readonly screen: Screen;
readonly skipped: number;
readonly totalRows: number;
serializePromoted(fromRow: number, toRow: number): string;
}
export function renderViewport(
node: LayoutNode,
width: number,
pools: RenderPools,
maxHeight: number,
): ViewportSplit {
const w = Math.max(0, width | 0);
if (w === 0) {
return {
screen: new Screen(0, 0),
skipped: 0,
totalRows: 0,
serializePromoted: () => "",
};
}
const laid = layout(node, w, pools);
const cap = Math.max(0, Math.floor(maxHeight));
const skipped = Math.max(0, laid.rows.length - cap);
const screen = blitRowSlice(laid.rows, skipped, laid.rows.length, w, pools);
return {
screen,
skipped,
totalRows: laid.rows.length,
serializePromoted: (fromRow, toRow) =>
serializeRowSlice(
laid.rows,
Math.max(0, fromRow),
Math.min(toRow, laid.rows.length),
w,
pools,
),
};
}
function blitRowSlice(
rows: LayoutRows,
fromRow: number,
toRow: number,
width: number,
pools: RenderPools,
): Screen {
const h = Math.max(0, toRow - fromRow);
const screen = new Screen(width, h);
for (let y = 0; y < h; y++) {
for (const frag of rows[y + fromRow]!) blitFragment(screen, frag, y, pools);
}
screen.resetDamage();
return screen;
}
export interface VirtualLayout {
readonly totalRows: number;
windowAt(scrollOffset: number, windowHeight: number): Screen;
}
export function renderVirtual(node: LayoutNode, width: number, pools: RenderPools): VirtualLayout {
const w = Math.max(0, width | 0);
if (w === 0) {
return {
totalRows: 0,
windowAt: () => new Screen(0, 0),
};
}
const laid = layout(node, w, pools);
return {
totalRows: laid.rows.length,
windowAt(scrollOffset, windowHeight) {
const start = Math.max(0, Math.min(laid.rows.length, Math.floor(scrollOffset)));
const end = Math.max(start, Math.min(laid.rows.length, start + Math.floor(windowHeight)));
return blitRowSlice(laid.rows, start, end, w, pools);
},
};
}
function serializeRowSlice(
rows: LayoutRows,
fromRow: number,
toRow: number,
width: number,
pools: RenderPools,
): string {
if (toRow <= fromRow) return "";
const screen = blitRowSlice(rows, fromRow, toRow, width, pools);
let out = "";
let curStyle = pools.style.none;
let curLink = 0;
for (let y = 0; y < screen.height; y++) {
if (y > 0) {
out += pools.style.transition(curStyle, pools.style.none);
curStyle = pools.style.none;
if (curLink !== 0) {
out += "\x1b]8;;\x1b\\";
curLink = 0;
}
out += "\r\n";
}
for (let x = 0; x < screen.width; x++) {
const cell = screen.cellAt(x, y);
if (!cell) continue;
if (cell.width === CellWidth.SpacerTail) continue;
if (cell.styleId !== curStyle) {
out += pools.style.transition(curStyle, cell.styleId);
curStyle = cell.styleId;
}
if (cell.hyperlinkId !== curLink) {
const uri = pools.hyperlink.get(cell.hyperlinkId) ?? "";
out += `\x1b]8;;${uri}\x1b\\`;
curLink = cell.hyperlinkId;
}
out += pools.char.get(cell.charId);
}
}
out += pools.style.transition(curStyle, pools.style.none);
if (curLink !== 0) out += "\x1b]8;;\x1b\\";
out += "\r\n";
return out;
}
function layout(node: LayoutNode, availableWidth: number, pools: RenderPools): Laid {
if (availableWidth <= 0) return { rows: [], width: 0 };
const built = buildYogaTree(node, pools, measureText);
built.root.calculateLayout(availableWidth, "auto", Direction.LTR);
// Yoga places the root at (marginLeft, marginTop) via getComputedLeft/Top,
// and getComputedHeight/Width excludes margins. Total render bounds =
// top-margin + content + bottom-margin (right-margin similarly for width).
const rootTop = Math.max(0, Math.round(built.root.getComputedTop()));
const rootLeft = Math.max(0, Math.round(built.root.getComputedLeft()));
const innerHeight = Math.max(0, Math.ceil(built.root.getComputedHeight()));
const innerWidth = Math.max(0, Math.ceil(built.root.getComputedWidth()));
const bottomMargin = node.kind === "box" ? Math.max(0, node.marginBottom ?? 0) : 0;
const rightMargin = node.kind === "box" ? Math.max(0, node.marginRight ?? 0) : 0;
const totalHeight = rootTop + innerHeight + bottomMargin;
const totalWidth = rootLeft + innerWidth + rightMargin;
const rows: LayoutRows = [];
for (let i = 0; i < totalHeight; i++) rows.push([]);
rasterize(built.root, built, 0, 0, rows, pools);
built.root.freeRecursive();
return { rows, width: totalWidth };
}
function rasterize(
yoga: YogaNode,
built: BuiltTree,
parentX: number,
parentY: number,
rows: LayoutRows,
pools: RenderPools,
): void {
const meta = built.meta.get(yoga);
if (!meta) return;
const left = parentX + Math.round(yoga.getComputedLeft());
const top = parentY + Math.round(yoga.getComputedTop());
const width = Math.max(0, Math.round(yoga.getComputedWidth()));
const height = Math.max(0, Math.round(yoga.getComputedHeight()));
if (meta.node.kind === "text") {
rasterizeText(meta.node, left, top, width, height, meta.styleId, meta.hyperlinkId, rows);
return;
}
const box = meta.node;
if (meta.border) {
paintBorder(meta, box, left, top, width, height, rows, pools);
}
const childList = built.children.get(yoga) ?? [];
for (const child of childList) {
rasterize(child, built, left, top, rows, pools);
}
}
function rasterizeText(
text: TextNode,
x: number,
y: number,
width: number,
height: number,
styleId: number,
hyperlinkId: number,
rows: LayoutRows,
): void {
if (width <= 0 || height <= 0 || y < 0) return;
const segmenter = getSegmenter();
let lineIdx = 0;
for (const rawLine of text.content.split("\n")) {
if (lineIdx >= height) break;
const wrapped = wrapLine(rawLine, width, segmenter);
if (wrapped.length === 0) {
pushFragment(rows, y + lineIdx, { graphemes: [], styleId, hyperlinkId, leftPad: x });
lineIdx++;
continue;
}
for (const row of wrapped) {
if (lineIdx >= height) break;
pushFragment(rows, y + lineIdx, { graphemes: row, styleId, hyperlinkId, leftPad: x });
lineIdx++;
}
}
}
function paintBorder(
meta: NodeMeta,
box: BoxNode,
left: number,
top: number,
width: number,
height: number,
rows: LayoutRows,
pools: RenderPools,
): void {
if (!meta.border || width <= 0 || height <= 0) return;
const border = meta.border;
const topColor = pickStyleId(box.borderTopColor ?? box.borderColor, pools);
const bottomColor = pickStyleId(box.borderBottomColor ?? box.borderColor, pools);
const leftColor = pickStyleId(box.borderLeftColor ?? box.borderColor, pools);
const rightColor = pickStyleId(box.borderRightColor ?? box.borderColor, pools);
if (meta.useTop) {
paintHorizontalEdge(
rows,
top,
left,
width,
border,
"top",
meta.useLeft,
meta.useRight,
topColor,
);
}
if (meta.useBottom && height > 1) {
paintHorizontalEdge(
rows,
top + height - 1,
left,
width,
border,
"bottom",
meta.useLeft,
meta.useRight,
bottomColor,
);
}
const innerTop = top + (meta.useTop ? 1 : 0);
const innerBottom = top + height - (meta.useBottom ? 2 : 1);
for (let r = innerTop; r <= innerBottom; r++) {
if (meta.useLeft) {
pushFragment(rows, r, {
graphemes: [{ glyph: border.left, width: Math.max(1, stringWidth(border.left)) }],
styleId: leftColor,
hyperlinkId: 0,
leftPad: left,
});
}
if (meta.useRight && width > 1) {
pushFragment(rows, r, {
graphemes: [{ glyph: border.right, width: Math.max(1, stringWidth(border.right)) }],
styleId: rightColor,
hyperlinkId: 0,
leftPad: left + width - 1,
});
}
}
}
function paintHorizontalEdge(
rows: LayoutRows,
y: number,
x: number,
width: number,
style: BorderStyle,
side: "top" | "bottom",
useLeft: boolean,
useRight: boolean,
styleId: number,
): void {
if (width <= 0 || y < 0) return;
const corners =
side === "top" ? [style.topLeft, style.topRight] : [style.bottomLeft, style.bottomRight];
const edge = side === "top" ? style.top : style.bottom;
const graphemes: Array<{ glyph: string; width: number }> = [];
for (let i = 0; i < width; i++) {
let glyph: string;
if (i === 0 && useLeft) glyph = corners[0]!;
else if (i === width - 1 && useRight) glyph = corners[1]!;
else glyph = edge;
graphemes.push({ glyph, width: Math.max(1, stringWidth(glyph)) });
}
pushFragment(rows, y, { graphemes, styleId, hyperlinkId: 0, leftPad: x });
}
function pushFragment(rows: LayoutRows, y: number, frag: RowFragment): void {
if (y < 0) return;
while (rows.length <= y) rows.push([]);
rows[y]!.push(frag);
}
function pickStyleId(color: ReadonlyArray<AnsiCode> | undefined, pools: RenderPools): number {
return color ? pools.style.intern(color) : pools.style.none;
}
// Yoga measure function for text leaves — must match wrapLine() at rasterization time.
function measureText(
content: string,
width: number,
widthMode: MeasureMode,
): { width: number; height: number } {
const segmenter = getSegmenter();
if (widthMode === MeasureMode.Undefined || width < 0) {
let maxW = 0;
let lines = 0;
for (const line of content.split("\n")) {
lines += 1;
const w = stringWidth(line);
if (w > maxW) maxW = w;
}
return { width: maxW, height: Math.max(1, lines) };
}
let maxW = 0;
let totalLines = 0;
for (const rawLine of content.split("\n")) {
const wrapped = wrapLine(rawLine, Math.floor(width), segmenter);
if (wrapped.length === 0) {
totalLines += 1;
continue;
}
totalLines += wrapped.length;
for (const row of wrapped) {
let w = 0;
for (const g of row) w += g.width;
if (w > maxW) maxW = w;
}
}
return { width: maxW, height: Math.max(1, totalLines) };
}
function wrapLine(
line: string,
width: number,
segmenter: Intl.Segmenter,
): Array<Array<{ glyph: string; width: number }>> {
if (width <= 0) return [];
const rows: Array<Array<{ glyph: string; width: number }>> = [];
let current: Array<{ glyph: string; width: number }> = [];
let used = 0;
for (const seg of segmenter.segment(line)) {
const glyph = seg.segment;
const gw = Math.max(0, stringWidth(glyph));
if (gw === 0) continue;
if (used + gw > width) {
if (current.length > 0) rows.push(current);
current = [];
used = 0;
if (gw > width) continue;
}
current.push({ glyph, width: gw });
used += gw;
}
if (current.length > 0) rows.push(current);
return rows;
}
function blitFragment(screen: Screen, frag: RowFragment, y: number, pools: RenderPools): void {
let x = frag.leftPad;
for (const { glyph, width } of frag.graphemes) {
if (x >= screen.width) break;
const charId = pools.char.intern(glyph);
const cell: Cell = {
charId,
styleId: frag.styleId,
hyperlinkId: frag.hyperlinkId,
width: width === 2 ? CellWidth.Wide : CellWidth.Single,
};
screen.writeCell(x, y, cell);
if (width === 2 && x + 1 < screen.width) {
screen.writeCell(x + 1, y, {
charId: 1,
styleId: frag.styleId,
hyperlinkId: frag.hyperlinkId,
width: CellWidth.SpacerTail,
});
}
x += width;
}
}
let _segmenter: Intl.Segmenter | undefined;
function getSegmenter(): Intl.Segmenter {
if (_segmenter) return _segmenter;
_segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
return _segmenter;
}
-67
View File
@@ -1,67 +0,0 @@
import type { AnsiCode } from "../pools/style-pool.js";
import type { BorderStyle, BorderStyleName } from "./borders.js";
export interface TextNode {
readonly kind: "text";
readonly content: string;
readonly style?: ReadonlyArray<AnsiCode>;
readonly hyperlink?: string;
}
export type JustifyContent =
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly";
export type AlignItems = "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
export type FlexWrap = "nowrap" | "wrap" | "wrap-reverse";
export type Display = "flex" | "none";
export interface BoxNode {
readonly kind: "box";
readonly children: ReadonlyArray<LayoutNode>;
readonly flexDirection?: "column" | "row" | "column-reverse" | "row-reverse";
readonly flexGrow?: number;
readonly flexShrink?: number;
readonly flexBasis?: number | `${number}%` | "auto";
readonly flexWrap?: FlexWrap;
readonly justifyContent?: JustifyContent;
readonly alignItems?: AlignItems;
readonly alignSelf?: AlignItems;
readonly alignContent?: AlignItems | "space-between" | "space-around";
readonly width?: number | `${number}%`;
readonly height?: number | `${number}%`;
readonly minWidth?: number | `${number}%`;
readonly minHeight?: number | `${number}%`;
readonly maxWidth?: number | `${number}%`;
readonly maxHeight?: number | `${number}%`;
readonly gap?: number;
readonly columnGap?: number;
readonly rowGap?: number;
readonly display?: Display;
readonly paddingTop?: number;
readonly paddingBottom?: number;
readonly paddingLeft?: number;
readonly paddingRight?: number;
readonly marginTop?: number;
readonly marginBottom?: number;
readonly marginLeft?: number;
readonly marginRight?: number;
readonly borderStyle?: BorderStyle | BorderStyleName;
readonly borderTop?: boolean;
readonly borderBottom?: boolean;
readonly borderLeft?: boolean;
readonly borderRight?: boolean;
readonly borderColor?: ReadonlyArray<AnsiCode>;
readonly borderTopColor?: ReadonlyArray<AnsiCode>;
readonly borderBottomColor?: ReadonlyArray<AnsiCode>;
readonly borderLeftColor?: ReadonlyArray<AnsiCode>;
readonly borderRightColor?: ReadonlyArray<AnsiCode>;
}
export type LayoutNode = TextNode | BoxNode;
-225
View File
@@ -1,225 +0,0 @@
import Yoga, {
Align,
BoxSizing,
Direction,
Display,
Edge,
FlexDirection,
Gutter,
Justify,
MeasureMode,
type MeasureFunction,
type Node as YogaNode,
Wrap,
} from "yoga-layout";
import type { CharPool } from "../pools/char-pool.js";
import type { HyperlinkPool } from "../pools/hyperlink-pool.js";
import type { StylePool } from "../pools/style-pool.js";
import { type BorderStyle, resolveBorderStyle } from "./borders.js";
import type { AlignItems, BoxNode, JustifyContent, LayoutNode, TextNode } from "./node.js";
export interface YogaPools {
readonly char: CharPool;
readonly style: StylePool;
readonly hyperlink: HyperlinkPool;
}
export interface NodeMeta {
readonly node: LayoutNode;
readonly border?: BorderStyle;
readonly useTop: boolean;
readonly useBottom: boolean;
readonly useLeft: boolean;
readonly useRight: boolean;
/** Text style/hyperlink interned at build time, applied during rasterization. */
readonly styleId: number;
readonly hyperlinkId: number;
}
export interface BuiltTree {
readonly root: YogaNode;
readonly meta: WeakMap<YogaNode, NodeMeta>;
/** Children in tree order so the rasterizer can walk without yoga's own tree walk. */
readonly children: ReadonlyMap<YogaNode, ReadonlyArray<YogaNode>>;
}
const ALIGN_MAP: Record<AlignItems, Align> = {
"flex-start": Align.FlexStart,
"flex-end": Align.FlexEnd,
center: Align.Center,
stretch: Align.Stretch,
baseline: Align.Baseline,
};
const JUSTIFY_MAP: Record<JustifyContent, Justify> = {
"flex-start": Justify.FlexStart,
"flex-end": Justify.FlexEnd,
center: Justify.Center,
"space-between": Justify.SpaceBetween,
"space-around": Justify.SpaceAround,
"space-evenly": Justify.SpaceEvenly,
};
/** Build a yoga tree from a LayoutNode. Caller owns the root and must call freeRecursive(). */
export function buildYogaTree(
node: LayoutNode,
pools: YogaPools,
measureText: (
content: string,
width: number,
widthMode: MeasureMode,
) => { width: number; height: number },
): BuiltTree {
const meta = new WeakMap<YogaNode, NodeMeta>();
const children = new Map<YogaNode, YogaNode[]>();
const root = build(node, pools, measureText, meta, children);
return { root, meta, children };
}
function build(
node: LayoutNode,
pools: YogaPools,
measureText: (
content: string,
width: number,
widthMode: MeasureMode,
) => { width: number; height: number },
meta: WeakMap<YogaNode, NodeMeta>,
children: Map<YogaNode, YogaNode[]>,
): YogaNode {
if (node.kind === "text") {
return buildText(node, pools, measureText, meta);
}
return buildBox(node, pools, measureText, meta, children);
}
function buildText(
node: TextNode,
pools: YogaPools,
measureText: (
content: string,
width: number,
widthMode: MeasureMode,
) => { width: number; height: number },
meta: WeakMap<YogaNode, NodeMeta>,
): YogaNode {
const yoga = Yoga.Node.create();
yoga.setBoxSizing(BoxSizing.BorderBox);
// Yoga's default flex-shrink is 0; CSS (and Ink) default is 1. Match CSS so
// long text wraps to fit narrower containers instead of overflowing.
yoga.setFlexShrink(1);
const measure: MeasureFunction = (width, widthMode) =>
measureText(node.content, width, widthMode);
yoga.setMeasureFunc(measure);
const styleId = node.style ? pools.style.intern(node.style) : pools.style.none;
const hyperlinkId = pools.hyperlink.intern(node.hyperlink);
meta.set(yoga, {
node,
useTop: false,
useBottom: false,
useLeft: false,
useRight: false,
styleId,
hyperlinkId,
});
return yoga;
}
function buildBox(
node: BoxNode,
pools: YogaPools,
measureText: (
content: string,
width: number,
widthMode: MeasureMode,
) => { width: number; height: number },
meta: WeakMap<YogaNode, NodeMeta>,
children: Map<YogaNode, YogaNode[]>,
): YogaNode {
const yoga = Yoga.Node.create();
yoga.setBoxSizing(BoxSizing.BorderBox);
// Default shrink=1 to match CSS / Ink ergonomics — applyBoxStyle overrides if explicit.
yoga.setFlexShrink(1);
applyBoxStyle(yoga, node);
const border = resolveBorderStyle(node.borderStyle);
const useTop = border !== undefined && node.borderTop !== false;
const useBottom = border !== undefined && node.borderBottom !== false;
const useLeft = border !== undefined && node.borderLeft !== false;
const useRight = border !== undefined && node.borderRight !== false;
if (useTop) yoga.setBorder(Edge.Top, 1);
if (useBottom) yoga.setBorder(Edge.Bottom, 1);
if (useLeft) yoga.setBorder(Edge.Left, 1);
if (useRight) yoga.setBorder(Edge.Right, 1);
meta.set(yoga, {
node,
border,
useTop,
useBottom,
useLeft,
useRight,
styleId: 0,
hyperlinkId: 0,
});
const childList: YogaNode[] = [];
for (let i = 0; i < node.children.length; i++) {
const childYoga = build(node.children[i]!, pools, measureText, meta, children);
yoga.insertChild(childYoga, i);
childList.push(childYoga);
}
children.set(yoga, childList);
return yoga;
}
function applyBoxStyle(yoga: YogaNode, node: BoxNode): void {
if (node.flexDirection === "row") yoga.setFlexDirection(FlexDirection.Row);
else if (node.flexDirection === "row-reverse") yoga.setFlexDirection(FlexDirection.RowReverse);
else if (node.flexDirection === "column-reverse")
yoga.setFlexDirection(FlexDirection.ColumnReverse);
else yoga.setFlexDirection(FlexDirection.Column);
if (node.flexGrow !== undefined) yoga.setFlexGrow(node.flexGrow);
if (node.flexShrink !== undefined) yoga.setFlexShrink(node.flexShrink);
if (node.flexBasis !== undefined) yoga.setFlexBasis(node.flexBasis);
if (node.flexWrap === "wrap") yoga.setFlexWrap(Wrap.Wrap);
else if (node.flexWrap === "wrap-reverse") yoga.setFlexWrap(Wrap.WrapReverse);
if (node.justifyContent) yoga.setJustifyContent(JUSTIFY_MAP[node.justifyContent]);
if (node.alignItems) yoga.setAlignItems(ALIGN_MAP[node.alignItems]);
if (node.alignSelf) yoga.setAlignSelf(ALIGN_MAP[node.alignSelf]);
if (node.alignContent) {
if (node.alignContent === "space-between") yoga.setAlignContent(Align.SpaceBetween);
else if (node.alignContent === "space-around") yoga.setAlignContent(Align.SpaceAround);
else yoga.setAlignContent(ALIGN_MAP[node.alignContent]);
}
if (node.width !== undefined) yoga.setWidth(node.width);
if (node.height !== undefined) yoga.setHeight(node.height);
if (node.minWidth !== undefined) yoga.setMinWidth(node.minWidth);
if (node.minHeight !== undefined) yoga.setMinHeight(node.minHeight);
if (node.maxWidth !== undefined) yoga.setMaxWidth(node.maxWidth);
if (node.maxHeight !== undefined) yoga.setMaxHeight(node.maxHeight);
// gap → both row + column gutters; specific overrides win.
if (node.gap !== undefined) yoga.setGap(Gutter.All, node.gap);
if (node.rowGap !== undefined) yoga.setGap(Gutter.Row, node.rowGap);
if (node.columnGap !== undefined) yoga.setGap(Gutter.Column, node.columnGap);
if (node.paddingTop !== undefined) yoga.setPadding(Edge.Top, node.paddingTop);
if (node.paddingBottom !== undefined) yoga.setPadding(Edge.Bottom, node.paddingBottom);
if (node.paddingLeft !== undefined) yoga.setPadding(Edge.Left, node.paddingLeft);
if (node.paddingRight !== undefined) yoga.setPadding(Edge.Right, node.paddingRight);
if (node.marginTop !== undefined) yoga.setMargin(Edge.Top, node.marginTop);
if (node.marginBottom !== undefined) yoga.setMargin(Edge.Bottom, node.marginBottom);
if (node.marginLeft !== undefined) yoga.setMargin(Edge.Left, node.marginLeft);
if (node.marginRight !== undefined) yoga.setMargin(Edge.Right, node.marginRight);
if (node.display === "none") yoga.setDisplay(Display.None);
}
export { Direction, MeasureMode };
-48
View File
@@ -1,48 +0,0 @@
const ASCII_TABLE_SIZE = 128;
const SPACE_ID = 0;
const EMPTY_ID = 1;
export class CharPool {
private readonly strings: string[];
private readonly map: Map<string, number>;
private readonly ascii: Int32Array;
constructor() {
this.strings = [" ", ""];
this.map = new Map([
[" ", SPACE_ID],
["", EMPTY_ID],
]);
this.ascii = new Int32Array(ASCII_TABLE_SIZE).fill(-1);
this.ascii[" ".charCodeAt(0)] = SPACE_ID;
}
intern(s: string): number {
if (s.length === 1) {
const code = s.charCodeAt(0);
if (code < ASCII_TABLE_SIZE) {
const cached = this.ascii[code]!;
if (cached !== -1) return cached;
const id = this.strings.length;
this.strings.push(s);
this.ascii[code] = id;
this.map.set(s, id);
return id;
}
}
const existing = this.map.get(s);
if (existing !== undefined) return existing;
const id = this.strings.length;
this.strings.push(s);
this.map.set(s, id);
return id;
}
get(id: number): string {
return this.strings[id] ?? " ";
}
get size(): number {
return this.strings.length;
}
}
-25
View File
@@ -1,25 +0,0 @@
const NO_HYPERLINK = 0;
export class HyperlinkPool {
private readonly strings: string[] = [""];
private readonly map = new Map<string, number>();
intern(uri: string | undefined): number {
if (!uri) return NO_HYPERLINK;
const existing = this.map.get(uri);
if (existing !== undefined) return existing;
const id = this.strings.length;
this.strings.push(uri);
this.map.set(uri, id);
return id;
}
get(id: number): string | undefined {
if (id === NO_HYPERLINK) return undefined;
return this.strings[id];
}
get size(): number {
return this.strings.length;
}
}
-58
View File
@@ -1,58 +0,0 @@
export interface AnsiCode {
readonly apply: string;
readonly revert: string;
}
const EMPTY_KEY = "";
export class StylePool {
private readonly stacks: ReadonlyArray<AnsiCode>[] = [[]];
private readonly idsByKey = new Map<string, number>([[EMPTY_KEY, 0]]);
private readonly transitionCache = new Map<number, string>();
readonly none = 0;
intern(codes: ReadonlyArray<AnsiCode>): number {
const key = stackKey(codes);
const existing = this.idsByKey.get(key);
if (existing !== undefined) return existing;
const id = this.stacks.length;
this.stacks.push(codes);
this.idsByKey.set(key, id);
return id;
}
transition(fromId: number, toId: number): string {
if (fromId === toId) return "";
const cacheKey = (fromId << 16) | toId;
const cached = this.transitionCache.get(cacheKey);
if (cached !== undefined) return cached;
const from = this.stacks[fromId] ?? [];
const to = this.stacks[toId] ?? [];
const fromApplies = new Set(from.map((c) => c.apply));
const toApplies = new Set(to.map((c) => c.apply));
let out = "";
for (const c of from) {
if (!toApplies.has(c.apply)) out += c.revert;
}
for (const c of to) {
if (!fromApplies.has(c.apply)) out += c.apply;
}
this.transitionCache.set(cacheKey, out);
return out;
}
get size(): number {
return this.stacks.length;
}
}
function stackKey(codes: ReadonlyArray<AnsiCode>): string {
if (codes.length === 0) return EMPTY_KEY;
// Sort so {RED, BOLD} and {BOLD, RED} hash to the same id — style identity is the SET of codes.
const applies = codes.map((c) => c.apply);
applies.sort();
return applies.join("\x00");
}
-68
View File
@@ -1,68 +0,0 @@
import React, { type ReactElement, type ReactNode } from "react";
import type { BorderStyle, BorderStyleName } from "../layout/borders.js";
import type { AlignItems, FlexWrap, JustifyContent } from "../layout/node.js";
import type { AnsiCode } from "../pools/style-pool.js";
export const HOST_BOX = "rsx-box" as const;
export const HOST_TEXT = "rsx-text" as const;
export interface BoxProps {
readonly children?: ReactNode;
readonly flexDirection?: "column" | "row" | "column-reverse" | "row-reverse";
readonly flexGrow?: number;
readonly flexShrink?: number;
readonly flexBasis?: number | `${number}%` | "auto";
readonly flexWrap?: FlexWrap;
readonly justifyContent?: JustifyContent;
readonly alignItems?: AlignItems;
readonly alignSelf?: AlignItems;
readonly alignContent?: AlignItems | "space-between" | "space-around";
readonly width?: number | `${number}%`;
readonly height?: number | `${number}%`;
readonly minWidth?: number | `${number}%`;
readonly minHeight?: number | `${number}%`;
readonly maxWidth?: number | `${number}%`;
readonly maxHeight?: number | `${number}%`;
readonly gap?: number;
readonly columnGap?: number;
readonly rowGap?: number;
readonly display?: "flex" | "none";
readonly paddingTop?: number;
readonly paddingBottom?: number;
readonly paddingLeft?: number;
readonly paddingRight?: number;
readonly paddingX?: number;
readonly paddingY?: number;
readonly padding?: number;
readonly marginTop?: number;
readonly marginBottom?: number;
readonly marginLeft?: number;
readonly marginRight?: number;
readonly marginX?: number;
readonly marginY?: number;
readonly margin?: number;
readonly borderStyle?: BorderStyle | BorderStyleName;
readonly borderTop?: boolean;
readonly borderBottom?: boolean;
readonly borderLeft?: boolean;
readonly borderRight?: boolean;
readonly borderColor?: ReadonlyArray<AnsiCode>;
readonly borderTopColor?: ReadonlyArray<AnsiCode>;
readonly borderBottomColor?: ReadonlyArray<AnsiCode>;
readonly borderLeftColor?: ReadonlyArray<AnsiCode>;
readonly borderRightColor?: ReadonlyArray<AnsiCode>;
}
export interface TextProps {
readonly children?: ReactNode;
readonly style?: ReadonlyArray<AnsiCode>;
readonly hyperlink?: string;
}
export function Box(props: BoxProps): ReactElement {
return React.createElement(HOST_BOX, props);
}
export function Text(props: TextProps): ReactElement {
return React.createElement(HOST_TEXT, props);
}
-142
View File
@@ -1,142 +0,0 @@
import { Fragment, type ReactElement, type ReactNode, isValidElement } from "react";
import { type RenderPools, renderToScreen } from "../layout/layout.js";
import type { BoxNode, LayoutNode, TextNode } from "../layout/node.js";
import type { Screen } from "../screen/screen.js";
import { type BoxProps, HOST_BOX, HOST_TEXT, type TextProps } from "./components.js";
export interface RenderOptions {
readonly width: number;
readonly pools: RenderPools;
}
export function render(element: ReactNode, opts: RenderOptions): Screen {
const node = treeToLayoutNode(element) ?? emptyBox();
return renderToScreen(node, opts.width, opts.pools);
}
function treeToLayoutNode(element: ReactNode): LayoutNode | null {
if (element === null || element === undefined || typeof element === "boolean") return null;
if (typeof element === "string" || typeof element === "number") {
return { kind: "text", content: String(element) };
}
if (Array.isArray(element)) {
const children = element.map(treeToLayoutNode).filter(notNull);
return children.length > 0 ? { kind: "box", children } : null;
}
if (isValidElement(element)) {
return elementToLayoutNode(element);
}
return null;
}
function elementToLayoutNode(element: ReactElement): LayoutNode | null {
const type = element.type;
const props = element.props as { children?: ReactNode };
if (type === HOST_BOX) {
const boxProps = element.props as BoxProps;
const children = childrenToLayoutNodes(props.children);
return assignAllBoxProps({ kind: "box", children }, boxProps);
}
if (type === HOST_TEXT) {
const textProps = element.props as TextProps;
return {
kind: "text",
content: collectText(textProps.children),
...(textProps.style ? { style: textProps.style } : {}),
...(textProps.hyperlink ? { hyperlink: textProps.hyperlink } : {}),
} satisfies TextNode;
}
if (type === Fragment) {
const children = childrenToLayoutNodes(props.children);
return children.length === 1 ? children[0]! : { kind: "box", children };
}
if (typeof type === "function") {
const Component = type as (props: unknown) => ReactNode;
return treeToLayoutNode(Component(props));
}
return null;
}
function childrenToLayoutNodes(children: ReactNode): LayoutNode[] {
if (children === null || children === undefined || typeof children === "boolean") return [];
if (Array.isArray(children)) {
return children.map(treeToLayoutNode).filter(notNull);
}
const node = treeToLayoutNode(children);
return node ? [node] : [];
}
function collectText(children: ReactNode): string {
if (children === null || children === undefined || typeof children === "boolean") return "";
if (typeof children === "string") return children;
if (typeof children === "number") return String(children);
if (Array.isArray(children)) {
return children.map(collectText).join("");
}
if (isValidElement(children)) {
const inner = (children.props as { children?: ReactNode }).children;
return collectText(inner);
}
return "";
}
function emptyBox(): BoxNode {
return { kind: "box", children: [] };
}
function assignAllBoxProps(node: BoxNode, props: BoxProps): BoxNode {
const r: { -readonly [K in keyof BoxNode]: BoxNode[K] } = { ...node };
const padAll = props.padding;
const padX = props.paddingX ?? padAll;
const padY = props.paddingY ?? padAll;
if ((props.paddingTop ?? padY) !== undefined) r.paddingTop = props.paddingTop ?? padY;
if ((props.paddingBottom ?? padY) !== undefined) r.paddingBottom = props.paddingBottom ?? padY;
if ((props.paddingLeft ?? padX) !== undefined) r.paddingLeft = props.paddingLeft ?? padX;
if ((props.paddingRight ?? padX) !== undefined) r.paddingRight = props.paddingRight ?? padX;
const marginAll = props.margin;
const marginX = props.marginX ?? marginAll;
const marginY = props.marginY ?? marginAll;
if ((props.marginTop ?? marginY) !== undefined) r.marginTop = props.marginTop ?? marginY;
if ((props.marginBottom ?? marginY) !== undefined) r.marginBottom = props.marginBottom ?? marginY;
if ((props.marginLeft ?? marginX) !== undefined) r.marginLeft = props.marginLeft ?? marginX;
if ((props.marginRight ?? marginX) !== undefined) r.marginRight = props.marginRight ?? marginX;
if (props.flexDirection !== undefined) r.flexDirection = props.flexDirection;
if (props.flexGrow !== undefined) r.flexGrow = props.flexGrow;
if (props.flexShrink !== undefined) r.flexShrink = props.flexShrink;
if (props.flexBasis !== undefined) r.flexBasis = props.flexBasis;
if (props.flexWrap !== undefined) r.flexWrap = props.flexWrap;
if (props.justifyContent !== undefined) r.justifyContent = props.justifyContent;
if (props.alignItems !== undefined) r.alignItems = props.alignItems;
if (props.alignSelf !== undefined) r.alignSelf = props.alignSelf;
if (props.alignContent !== undefined) r.alignContent = props.alignContent;
if (props.width !== undefined) r.width = props.width;
if (props.height !== undefined) r.height = props.height;
if (props.minWidth !== undefined) r.minWidth = props.minWidth;
if (props.minHeight !== undefined) r.minHeight = props.minHeight;
if (props.maxWidth !== undefined) r.maxWidth = props.maxWidth;
if (props.maxHeight !== undefined) r.maxHeight = props.maxHeight;
if (props.gap !== undefined) r.gap = props.gap;
if (props.columnGap !== undefined) r.columnGap = props.columnGap;
if (props.rowGap !== undefined) r.rowGap = props.rowGap;
if (props.display !== undefined) r.display = props.display;
if (props.borderStyle !== undefined) r.borderStyle = props.borderStyle;
if (props.borderTop !== undefined) r.borderTop = props.borderTop;
if (props.borderBottom !== undefined) r.borderBottom = props.borderBottom;
if (props.borderLeft !== undefined) r.borderLeft = props.borderLeft;
if (props.borderRight !== undefined) r.borderRight = props.borderRight;
if (props.borderColor !== undefined) r.borderColor = props.borderColor;
if (props.borderTopColor !== undefined) r.borderTopColor = props.borderTopColor;
if (props.borderBottomColor !== undefined) r.borderBottomColor = props.borderBottomColor;
if (props.borderLeftColor !== undefined) r.borderLeftColor = props.borderLeftColor;
if (props.borderRightColor !== undefined) r.borderRightColor = props.borderRightColor;
return r;
}
function notNull<T>(v: T | null): v is T {
return v !== null;
}
-36
View File
@@ -1,36 +0,0 @@
/** Cursor request from React → renderer. mount() polls this on every frame commit. */
import { createContext, useContext, useEffect } from "react";
export interface CursorTarget {
/** Column from the left edge of the viewport (0-based). Wide chars count as 2. */
readonly col: number;
/** Row from the BOTTOM of the rendered screen (0 = last row). Default 0. */
readonly rowFromBottom?: number;
/** Hide the terminal cursor when false. Default true. */
readonly visible?: boolean;
}
export type CursorSetter = (target: CursorTarget | null) => void;
export const CursorContext = createContext<CursorSetter | null>(null);
/** Register / replace / clear the cursor target. Pass `null` to release. */
export function useCursor(target: CursorTarget | null): void {
const setter = useContext(CursorContext);
// Snapshot to primitive scalars so re-renders with structurally equal
// targets don't re-fire the effect (callers usually pass fresh object literals).
const isSet = target !== null;
const col = target?.col ?? 0;
const rowFromBottom = target?.rowFromBottom ?? 0;
const visible = target?.visible !== false;
useEffect(() => {
if (!setter) return;
if (!isSet) {
setter(null);
return;
}
setter({ col, rowFromBottom, visible });
return () => setter(null);
}, [setter, isSet, col, rowFromBottom, visible]);
}
-193
View File
@@ -1,193 +0,0 @@
import ReactReconciler from "react-reconciler";
import { type BoxProps, HOST_BOX, HOST_TEXT, type TextProps } from "../react/components.js";
import {
type HostBox,
type HostNode,
type HostText,
hostToLayoutNode,
makeHostBox,
makeHostRawText,
makeHostText,
} from "./host-node.js";
export interface HostRoot {
readonly children: HostNode[];
onCommit: () => void;
}
type Container = HostRoot;
type Type = typeof HOST_BOX | typeof HOST_TEXT | string;
type Props = BoxProps | TextProps;
type Instance = HostBox | HostText;
type TextInstance = ReturnType<typeof makeHostRawText>;
type PublicInstance = Instance;
type HostContext = Record<string, never>;
type UpdatePayload = Props;
type ChildSet = never;
type TimeoutHandle = ReturnType<typeof setTimeout>;
type NoTimeout = -1;
const hostConfig: ReactReconciler.HostConfig<
Type,
Props,
Container,
Instance,
TextInstance,
never,
never,
PublicInstance,
HostContext,
UpdatePayload,
ChildSet,
TimeoutHandle,
NoTimeout
> = {
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
// We're the only React renderer in this process. `false` would put us on
// React's shared non-primary context-stack — re-entrant updateContainer
// (the Static / emitStatic path) crashed against that. `true` gives us our
// own context state, no shared-stack collisions.
isPrimaryRenderer: true,
noTimeout: -1,
createInstance(type, props) {
if (type === HOST_BOX) return makeHostBox(props as BoxProps);
if (type === HOST_TEXT) return makeHostText(props as TextProps);
throw new Error(`Reasonix renderer: unsupported element type: ${String(type)}`);
},
createTextInstance(text) {
return makeHostRawText(text);
},
appendInitialChild(parent, child) {
parent.children.push(child as HostNode);
(child as HostNode).parent = parent;
},
appendChild(parent, child) {
parent.children.push(child as HostNode);
(child as HostNode).parent = parent;
},
appendChildToContainer(container, child) {
container.children.push(child as HostNode);
(child as HostNode).parent = null;
},
insertBefore(parent, child, beforeChild) {
const idx = parent.children.indexOf(beforeChild as HostNode);
if (idx < 0) parent.children.push(child as HostNode);
else parent.children.splice(idx, 0, child as HostNode);
(child as HostNode).parent = parent;
},
insertInContainerBefore(container, child, beforeChild) {
const idx = container.children.indexOf(beforeChild as HostNode);
if (idx < 0) container.children.push(child as HostNode);
else container.children.splice(idx, 0, child as HostNode);
(child as HostNode).parent = null;
},
removeChild(parent, child) {
const idx = parent.children.indexOf(child as HostNode);
if (idx >= 0) parent.children.splice(idx, 1);
(child as HostNode).parent = null;
},
removeChildFromContainer(container, child) {
const idx = container.children.indexOf(child as HostNode);
if (idx >= 0) container.children.splice(idx, 1);
},
finalizeInitialChildren() {
return false;
},
prepareUpdate(_instance, _type, _oldProps, newProps) {
return newProps;
},
commitUpdate(instance, payload) {
instance.props = payload;
},
commitTextUpdate(textInstance, _oldText, newText) {
textInstance.text = newText;
},
resetTextContent() {
/* no-op */
},
shouldSetTextContent() {
return false;
},
getRootHostContext() {
return {};
},
getChildHostContext() {
return {};
},
getPublicInstance(instance) {
return instance as Instance;
},
prepareForCommit() {
return null;
},
resetAfterCommit(container) {
container.onCommit();
},
preparePortalMount() {
/* no-op */
},
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
getCurrentEventPriority() {
return DefaultEventPriority;
},
getInstanceFromNode() {
return null;
},
beforeActiveInstanceBlur() {
/* no-op */
},
afterActiveInstanceBlur() {
/* no-op */
},
prepareScopeUpdate() {
/* no-op */
},
getInstanceFromScope() {
return null;
},
detachDeletedInstance() {
/* no-op */
},
clearContainer(container) {
container.children.length = 0;
},
};
const DefaultEventPriority = 16;
export const reconciler = ReactReconciler(hostConfig);
export { hostToLayoutNode };
-131
View File
@@ -1,131 +0,0 @@
import type { BoxNode, LayoutNode, TextNode } from "../layout/node.js";
import type { BoxProps, TextProps } from "../react/components.js";
export type HostType = "box" | "text" | "raw-text";
export interface HostBox {
readonly type: "box";
props: BoxProps;
children: HostNode[];
parent: HostBox | null;
}
export interface HostText {
readonly type: "text";
props: TextProps;
children: HostNode[];
parent: HostBox | null;
}
export interface HostRawText {
readonly type: "raw-text";
text: string;
parent: HostBox | HostText | null;
}
export type HostNode = HostBox | HostText | HostRawText;
export function makeHostBox(props: BoxProps): HostBox {
return { type: "box", props, children: [], parent: null };
}
export function makeHostText(props: TextProps): HostText {
return { type: "text", props, children: [], parent: null };
}
export function makeHostRawText(text: string): HostRawText {
return { type: "raw-text", text, parent: null };
}
export function hostToLayoutNode(node: HostNode): LayoutNode | null {
if (node.type === "raw-text") {
return { kind: "text", content: node.text };
}
if (node.type === "text") {
return {
kind: "text",
content: collectText(node.children),
...(node.props.style ? { style: node.props.style } : {}),
...(node.props.hyperlink ? { hyperlink: node.props.hyperlink } : {}),
} satisfies TextNode;
}
const layoutChildren: LayoutNode[] = [];
for (const c of node.children) {
const child = hostToLayoutNode(c);
if (child) layoutChildren.push(child);
}
const p = node.props;
const out: BoxNode = { kind: "box", children: layoutChildren };
return assignBoxProps(out, p);
}
function collectText(children: HostNode[]): string {
let out = "";
for (const child of children) {
if (child.type === "raw-text") out += child.text;
else if (child.type === "text") out += collectText(child.children);
}
return out;
}
function assignBoxProps(node: BoxNode, props: BoxProps): BoxNode {
const result: { -readonly [K in keyof BoxNode]: BoxNode[K] } = { ...node };
// padding shorthand: padding > paddingX/Y > side-specific
const padAll = props.padding;
const padX = props.paddingX ?? padAll;
const padY = props.paddingY ?? padAll;
const padTop = props.paddingTop ?? padY;
const padBottom = props.paddingBottom ?? padY;
const padLeft = props.paddingLeft ?? padX;
const padRight = props.paddingRight ?? padX;
if (padTop !== undefined) result.paddingTop = padTop;
if (padBottom !== undefined) result.paddingBottom = padBottom;
if (padLeft !== undefined) result.paddingLeft = padLeft;
if (padRight !== undefined) result.paddingRight = padRight;
// margin shorthand: margin > marginX/Y > side-specific
const marginAll = props.margin;
const marginX = props.marginX ?? marginAll;
const marginY = props.marginY ?? marginAll;
const marginTop = props.marginTop ?? marginY;
const marginBottom = props.marginBottom ?? marginY;
const marginLeft = props.marginLeft ?? marginX;
const marginRight = props.marginRight ?? marginX;
if (marginTop !== undefined) result.marginTop = marginTop;
if (marginBottom !== undefined) result.marginBottom = marginBottom;
if (marginLeft !== undefined) result.marginLeft = marginLeft;
if (marginRight !== undefined) result.marginRight = marginRight;
if (props.flexDirection !== undefined) result.flexDirection = props.flexDirection;
if (props.flexGrow !== undefined) result.flexGrow = props.flexGrow;
if (props.flexShrink !== undefined) result.flexShrink = props.flexShrink;
if (props.flexBasis !== undefined) result.flexBasis = props.flexBasis;
if (props.flexWrap !== undefined) result.flexWrap = props.flexWrap;
if (props.justifyContent !== undefined) result.justifyContent = props.justifyContent;
if (props.alignItems !== undefined) result.alignItems = props.alignItems;
if (props.alignSelf !== undefined) result.alignSelf = props.alignSelf;
if (props.alignContent !== undefined) result.alignContent = props.alignContent;
if (props.width !== undefined) result.width = props.width;
if (props.height !== undefined) result.height = props.height;
if (props.minWidth !== undefined) result.minWidth = props.minWidth;
if (props.minHeight !== undefined) result.minHeight = props.minHeight;
if (props.maxWidth !== undefined) result.maxWidth = props.maxWidth;
if (props.maxHeight !== undefined) result.maxHeight = props.maxHeight;
if (props.gap !== undefined) result.gap = props.gap;
if (props.columnGap !== undefined) result.columnGap = props.columnGap;
if (props.rowGap !== undefined) result.rowGap = props.rowGap;
if (props.display !== undefined) result.display = props.display;
if (props.borderStyle !== undefined) result.borderStyle = props.borderStyle;
if (props.borderTop !== undefined) result.borderTop = props.borderTop;
if (props.borderBottom !== undefined) result.borderBottom = props.borderBottom;
if (props.borderLeft !== undefined) result.borderLeft = props.borderLeft;
if (props.borderRight !== undefined) result.borderRight = props.borderRight;
if (props.borderColor !== undefined) result.borderColor = props.borderColor;
if (props.borderTopColor !== undefined) result.borderTopColor = props.borderTopColor;
if (props.borderBottomColor !== undefined) result.borderBottomColor = props.borderBottomColor;
if (props.borderLeftColor !== undefined) result.borderLeftColor = props.borderLeftColor;
if (props.borderRightColor !== undefined) result.borderRightColor = props.borderRightColor;
return result;
}
-367
View File
@@ -1,367 +0,0 @@
import { type ReactNode, createElement } from "react";
import { type DiffPools, diffFrames } from "../diff/diff-frames.js";
import { type Cursor, type Frame, emptyFrame } from "../diff/frame.js";
import { serializePatches } from "../diff/serialize.js";
import { RendererBridgeContext } from "../ink-compat/renderer-bridge.js";
import { AppContext } from "../ink-compat/use-app.js";
import { StdoutContext, ViewportContext } from "../ink-compat/viewport.js";
import { KeystrokeContext, KeystrokeReader, type KeystrokeSource } from "../input/index.js";
import { renderViewport, renderVirtual } from "../layout/layout.js";
import type { LayoutNode } from "../layout/node.js";
import { renderToBytes } from "../runtime/render-to-bytes.js";
import { CursorContext, type CursorTarget } from "./cursor.js";
import { type HostRoot, hostToLayoutNode, reconciler } from "./host-config.js";
export type ScrollMode = "scrollback" | "virtual";
export interface MountOptions {
readonly viewportWidth: number;
readonly viewportHeight: number;
readonly pools: DiffPools;
readonly write: (bytes: string) => void;
readonly cursor?: () => Cursor;
readonly stdin?: KeystrokeSource;
readonly onExit?: (error?: Error) => void;
/** "scrollback" (default): rows that overflow viewport go to scrollback, frozen.
* "virtual": full layout stays live in memory; PgUp/PgDown/Home/End scroll within. */
readonly scroll?: ScrollMode;
/** Real stdout stream — exposed to consumers via `inkCompat.useStdout()` so
* components that need `.on("resize", …)` / `.isTTY` etc. get the actual
* WriteStream rather than a `{columns,rows}` fake. Defaults to process.stdout. */
readonly stdout?: NodeJS.WriteStream;
}
export interface Handle {
update(element: ReactNode): void;
resize(width: number, height: number): void;
emitStatic(element: ReactNode): void;
destroy(): void;
}
const RESET_SGR = "\x1b[0m";
const CLOSE_HYPERLINK = "\x1b]8;;\x1b\\";
// alt-screen + button-event mouse + SGR coords. Shift+drag bypasses for native selection.
const ENTER_VIRTUAL = "\x1b[?1049h\x1b[2J\x1b[H\x1b[?1002h\x1b[?1006h";
const LEAVE_VIRTUAL = "\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?1049l";
// DEC 2026 synchronized output — defers paint until ESU so half-arrived diffs don't flash.
const BSU = "\x1b[?2026h";
const ESU = "\x1b[?2026l";
export function mount(element: ReactNode, opts: MountOptions): Handle {
const scrollMode: ScrollMode = opts.scroll ?? "scrollback";
let viewportWidth = opts.viewportWidth;
let viewportHeight = opts.viewportHeight;
let frame: Frame = emptyFrame(viewportWidth, viewportHeight);
let reservedRows = 0;
let promotedRows = 0;
let scrollOffset = 0;
let stickyBottom = true;
let lastTotalRows = 0;
let destroyed = false;
let lastElement: ReactNode = element;
// Set by resize(); -1 = none. \x1b[2J would scroll content into scrollback
// on Windows conhost, so we rewind + \x1b[J to fill in place instead.
let pendingClearRewind = -1;
// Empty input is dropped — bare BSU+ESU can briefly flash on some terminals.
const flushFrame = (out: string): void => {
let buffer = out;
if (pendingClearRewind >= 0) {
const r = pendingClearRewind;
buffer = (r > 0 ? `\r\x1b[${r}A\x1b[J` : "\r\x1b[J") + buffer;
pendingClearRewind = -1;
}
if (buffer.length === 0) return;
opts.write(BSU + buffer + ESU);
};
const reader = opts.stdin ? new KeystrokeReader({ source: opts.stdin }) : null;
let cursorTarget: CursorTarget | null = null;
const setCursorTarget = (next: CursorTarget | null): void => {
cursorTarget = next;
if (!destroyed && root.children.length > 0) root.onCommit();
};
const computeCursor = (screenHeight: number): Cursor => {
if (cursorTarget) {
const rowFromBottom = Math.max(0, cursorTarget.rowFromBottom ?? 0);
const y = Math.max(0, screenHeight - 1 - rowFromBottom);
return {
x: Math.max(0, cursorTarget.col),
y,
visible: cursorTarget.visible !== false,
};
}
if (opts.cursor) return opts.cursor();
return { x: 0, y: screenHeight, visible: true };
};
const scrollBy = (delta: number): void => {
if (scrollMode !== "virtual") return;
const window = Math.max(1, viewportHeight - 1);
const maxOffset = Math.max(0, lastTotalRows - window);
const next = Math.max(0, Math.min(maxOffset, scrollOffset + delta));
if (next === scrollOffset) return;
scrollOffset = next;
stickyBottom = next === maxOffset;
if (root.children.length > 0) {
root.onCommit();
}
};
let exitCleanup: (() => void) | null = null;
let signalCleanup: (() => void) | null = null;
if (scrollMode === "virtual") {
opts.write(ENTER_VIRTUAL);
if (reader) {
reader.subscribe((k) => {
const window = Math.max(1, viewportHeight - 1);
if (k.pageUp) scrollBy(-window);
else if (k.pageDown) scrollBy(window);
else if (k.home) scrollBy(-Number.MAX_SAFE_INTEGER);
else if (k.end) scrollBy(Number.MAX_SAFE_INTEGER);
else if (k.wheelUp) scrollBy(-3);
else if (k.wheelDown) scrollBy(3);
});
}
if (typeof process !== "undefined" && process.on) {
exitCleanup = () => {
try {
opts.write(LEAVE_VIRTUAL);
} catch {
/* stdout already closed */
}
};
signalCleanup = () => process.exit(130);
process.on("exit", exitCleanup);
process.on("SIGINT", signalCleanup);
process.on("SIGTERM", signalCleanup);
}
}
const commitScrollback = (layout: LayoutNode): void => {
const maxHeight = Math.max(1, viewportHeight - 1);
const split = renderViewport(layout, viewportWidth, opts.pools, maxHeight);
const newPromote = Math.max(0, split.skipped - promotedRows);
// Buffer every byte for this frame into ONE string so the terminal can't
// flush between sub-steps. Multiple writes was visible flicker (modal
// mount + streaming chunks each hitting reserve-grow + patches separately).
let out = "";
if (newPromote > 0) {
if (frame.screen.height > 0) {
out += `\r\x1b[${frame.screen.height}A\x1b[J`;
} else if (reservedRows === 0) {
out += "\r\x1b[J";
}
out += split.serializePromoted(promotedRows, split.skipped);
promotedRows = split.skipped;
frame = emptyFrame(viewportWidth, viewportHeight);
reservedRows = 0;
}
const screen = split.screen;
const targetReserve = Math.min(screen.height, Math.max(0, viewportHeight - 1));
if (targetReserve > reservedRows) {
const delta = targetReserve - reservedRows;
if (reservedRows === 0) out += "\r";
out += `${"\n".repeat(delta)}\x1b[${delta}A`;
reservedRows = targetReserve;
}
const next: Frame = {
screen,
viewportWidth,
viewportHeight,
cursor: computeCursor(screen.height),
};
const patches = diffFrames(frame, next, opts.pools);
if (patches.length > 0) out += serializePatches(patches);
flushFrame(out);
frame = next;
};
const commitVirtual = (layout: LayoutNode): void => {
const window = Math.max(1, viewportHeight - 1);
const virt = renderVirtual(layout, viewportWidth, opts.pools);
const maxOffset = Math.max(0, virt.totalRows - window);
if (stickyBottom || virt.totalRows < lastTotalRows) {
scrollOffset = maxOffset;
} else {
scrollOffset = Math.min(scrollOffset, maxOffset);
}
lastTotalRows = virt.totalRows;
const screen = virt.windowAt(scrollOffset, window);
const targetReserve = Math.min(window, Math.max(0, viewportHeight - 1));
let out = "";
if (targetReserve > reservedRows) {
const delta = targetReserve - reservedRows;
if (reservedRows === 0) out += "\r";
out += `${"\n".repeat(delta)}\x1b[${delta}A`;
reservedRows = targetReserve;
}
const next: Frame = {
screen,
viewportWidth,
viewportHeight,
cursor: computeCursor(screen.height),
};
const patches = diffFrames(frame, next, opts.pools);
if (patches.length > 0) out += serializePatches(patches);
flushFrame(out);
frame = next;
};
const root: HostRoot = {
children: [],
onCommit: () => {
if (destroyed) return;
const layout = collectRootLayout(root.children);
if (scrollMode === "virtual") {
commitVirtual(layout);
} else {
commitScrollback(layout);
}
},
};
const container = reconciler.createContainer(
root,
0,
null,
false,
null,
"rsx",
() => {
/* recoverable error */
},
null,
);
// Fires from Static's useEffect (mid-commit). queueMicrotask defers the
// re-entrant updateContainer until the live commit fully drains —
// otherwise root.children stays empty when renderToBytes reads it.
const emitStatic = (node: ReactNode): void => {
if (destroyed) return;
queueMicrotask(() => {
if (destroyed) return;
const bytes = renderToBytes(node, viewportWidth, opts.pools);
if (bytes.length === 0) return;
let out = "";
if (frame.screen.height > 0) {
out += `\r\x1b[${frame.screen.height}A\x1b[J`;
} else {
out += "\r\x1b[J";
}
out += bytes;
flushFrame(out);
// Static bytes consumed the previously-reserved live rows — drop the
// reservation so the next commit re-grows it from the cursor's new
// position, otherwise live paints onto rows now in scrollback.
frame = emptyFrame(viewportWidth, viewportHeight);
reservedRows = 0;
root.onCommit();
});
};
const bridge = { emitStatic };
const appApi = {
exit: (error?: Error): void => {
if (destroyed) return;
queueMicrotask(() => opts.onExit?.(error));
},
};
const wrap = (node: ReactNode): ReactNode => {
const withApp: ReactNode = createElement(AppContext.Provider, { value: appApi }, node);
const withBridge: ReactNode = createElement(
RendererBridgeContext.Provider,
{ value: bridge },
withApp,
);
const withViewport: ReactNode = createElement(
ViewportContext.Provider,
{ value: { columns: viewportWidth, rows: viewportHeight } },
withBridge,
);
const stdoutForCtx =
opts.stdout ??
(typeof process !== "undefined" ? (process.stdout as NodeJS.WriteStream) : undefined);
const withStdout: ReactNode = stdoutForCtx
? createElement(StdoutContext.Provider, { value: stdoutForCtx }, withViewport)
: withViewport;
const withCursor: ReactNode = createElement(
CursorContext.Provider,
{ value: setCursorTarget },
withStdout,
);
return reader
? createElement(KeystrokeContext.Provider, { value: reader }, withCursor)
: withCursor;
};
reconciler.updateContainer(wrap(element), container, null, () => {
/* committed */
});
return {
update(nextElement: ReactNode): void {
if (destroyed) return;
lastElement = nextElement;
reconciler.updateContainer(wrap(nextElement), container, null, () => {
/* committed */
});
},
resize(width: number, height: number): void {
if (destroyed) return;
// Same-dim events fire 2-3× per drag on most terminals; skip duplicates.
if (width === viewportWidth && height === viewportHeight) return;
// Capture rewind distance before discarding frame state below.
if (frame.screen.height > 0 || reservedRows > 0) {
pendingClearRewind = Math.max(0, frame.cursor.y);
}
viewportWidth = width;
viewportHeight = height;
frame = emptyFrame(width, height);
reservedRows = 0;
// Keep `promotedRows` as-is: those rows are already in scrollback. Resetting
// would make the next commit re-promote everything and stack a duplicate
// copy of every overflowed row.
scrollOffset = 0;
stickyBottom = true;
lastTotalRows = 0;
reconciler.updateContainer(wrap(lastElement), container, null, () => {
/* committed */
});
},
emitStatic,
destroy(): void {
if (destroyed) return;
destroyed = true;
reader?.destroy();
reconciler.updateContainer(null, container, null, () => {
/* committed */
});
const teardown = scrollMode === "virtual" ? LEAVE_VIRTUAL : "";
opts.write(`${teardown}${RESET_SGR}${CLOSE_HYPERLINK}`);
if (exitCleanup && typeof process !== "undefined" && process.off) {
process.off("exit", exitCleanup);
exitCleanup = null;
}
if (signalCleanup && typeof process !== "undefined" && process.off) {
process.off("SIGINT", signalCleanup);
process.off("SIGTERM", signalCleanup);
signalCleanup = null;
}
},
};
}
function collectRootLayout(children: ReadonlyArray<unknown>): LayoutNode {
const layoutChildren: LayoutNode[] = [];
for (const c of children) {
const child = hostToLayoutNode(c as Parameters<typeof hostToLayoutNode>[0]);
if (child) layoutChildren.push(child);
}
if (layoutChildren.length === 1) return layoutChildren[0]!;
return { kind: "box", children: layoutChildren };
}
-96
View File
@@ -1,96 +0,0 @@
/** One-shot offline render of a React tree to ANSI bytes. */
import { type ReactNode, createElement } from "react";
import type { DiffPools } from "../diff/diff-frames.js";
import { ViewportContext } from "../ink-compat/viewport.js";
import { renderToScreen } from "../layout/layout.js";
import type { LayoutNode } from "../layout/node.js";
import { type HostRoot, hostToLayoutNode, reconciler } from "../reconciler/host-config.js";
import { CellWidth } from "../screen/cell.js";
const ESC = "\x1b";
const ST = `${ESC}\\`;
export function renderToBytes(element: ReactNode, width: number, pools: DiffPools): string {
const layout = renderTreeToLayout(element, width);
const screen = renderToScreen(layout, width, pools);
if (screen.height === 0) return "";
let out = "";
let curStyle = pools.style.none;
let curLink = 0;
for (let y = 0; y < screen.height; y++) {
if (y > 0) {
out += pools.style.transition(curStyle, pools.style.none);
curStyle = pools.style.none;
if (curLink !== 0) {
out += `${ESC}]8;;${ST}`;
curLink = 0;
}
out += "\r\n";
}
for (let x = 0; x < screen.width; x++) {
const cell = screen.cellAt(x, y);
if (!cell) continue;
if (cell.width === CellWidth.SpacerTail) continue;
if (cell.styleId !== curStyle) {
out += pools.style.transition(curStyle, cell.styleId);
curStyle = cell.styleId;
}
if (cell.hyperlinkId !== curLink) {
const uri = pools.hyperlink.get(cell.hyperlinkId) ?? "";
out += `${ESC}]8;;${uri}${ST}`;
curLink = cell.hyperlinkId;
}
out += pools.char.get(cell.charId);
}
}
out += pools.style.transition(curStyle, pools.style.none);
if (curLink !== 0) out += `${ESC}]8;;${ST}`;
out += "\r\n";
return out;
}
/** Render the element through a fresh reconciler container, walk the resulting
* host tree to a layout node, then unmount. Synchronous: react-reconciler
* with isPrimaryRenderer=false + LegacyMode commits eagerly. */
function renderTreeToLayout(element: ReactNode, width: number): LayoutNode {
const root: HostRoot = {
children: [],
onCommit: () => {
/* one-shot — caller reads root.children directly */
},
};
const container = reconciler.createContainer(
root,
0, // LegacyMode — sync
null,
false,
null,
"rsx-static",
() => {
/* recoverable error */
},
null,
);
const wrapped = createElement(
ViewportContext.Provider,
{ value: { columns: width, rows: 24 } },
element,
);
reconciler.updateContainer(wrapped, container, null, () => {});
const layoutChildren: LayoutNode[] = [];
for (const c of root.children) {
const child = hostToLayoutNode(c);
if (child) layoutChildren.push(child);
}
const layout: LayoutNode =
layoutChildren.length === 1 ? layoutChildren[0]! : { kind: "box", children: layoutChildren };
reconciler.updateContainer(null, container, null, () => {});
return layout;
}
-62
View File
@@ -1,62 +0,0 @@
import type { ReactNode } from "react";
import { type DiffPools, diffFrames } from "../diff/diff-frames.js";
import { type Cursor, type Frame, emptyFrame } from "../diff/frame.js";
import { serializePatches } from "../diff/serialize.js";
import { render as renderReactTree } from "../react/render.js";
export interface RendererOptions {
readonly viewportWidth: number;
readonly viewportHeight: number;
readonly pools: DiffPools;
readonly write: (bytes: string) => void;
}
const RESET_SGR = "\x1b[0m";
const CLOSE_HYPERLINK = "\x1b]8;;\x1b\\";
// DEC 2026 synchronized output — defers paint until ESU so half-arrived diffs don't flash.
const BSU = "\x1b[?2026h";
const ESU = "\x1b[?2026l";
export class Renderer {
private viewportWidth: number;
private viewportHeight: number;
private frame: Frame;
private destroyed = false;
constructor(private readonly opts: RendererOptions) {
this.viewportWidth = opts.viewportWidth;
this.viewportHeight = opts.viewportHeight;
this.frame = emptyFrame(this.viewportWidth, this.viewportHeight);
}
update(element: ReactNode, cursor?: Cursor): void {
if (this.destroyed) return;
const screen = renderReactTree(element, {
width: this.viewportWidth,
pools: this.opts.pools,
});
const next: Frame = {
screen,
viewportWidth: this.viewportWidth,
viewportHeight: this.viewportHeight,
cursor: cursor ?? { x: 0, y: screen.height, visible: true },
};
const patches = diffFrames(this.frame, next, this.opts.pools);
if (patches.length > 0) {
this.opts.write(BSU + serializePatches(patches) + ESU);
}
this.frame = next;
}
resize(width: number, height: number): void {
if (this.destroyed) return;
this.viewportWidth = width;
this.viewportHeight = height;
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.opts.write(`${RESET_SGR}${CLOSE_HYPERLINK}`);
}
}
-22
View File
@@ -1,22 +0,0 @@
export interface TestWriter {
readonly write: (bytes: string) => void;
output(): string;
flush(): string;
chunks(): ReadonlyArray<string>;
}
export function makeTestWriter(): TestWriter {
const buf: string[] = [];
return {
write: (bytes: string) => {
buf.push(bytes);
},
output: () => buf.join(""),
flush: () => {
const out = buf.join("");
buf.length = 0;
return out;
},
chunks: () => buf.slice(),
};
}
-31
View File
@@ -1,31 +0,0 @@
export const CellWidth = {
Single: 1,
Wide: 2,
/** Occupies the column following a Wide glyph; renderer must skip it. */
SpacerTail: 3,
} as const;
export type CellWidth = (typeof CellWidth)[keyof typeof CellWidth];
export interface Cell {
charId: number;
styleId: number;
hyperlinkId: number;
width: CellWidth;
}
export const EMPTY_CELL: Cell = Object.freeze({
charId: 0,
styleId: 0,
hyperlinkId: 0,
width: CellWidth.Single,
});
export function cellsEqual(a: Cell, b: Cell): boolean {
return (
a.charId === b.charId &&
a.styleId === b.styleId &&
a.hyperlinkId === b.hyperlinkId &&
a.width === b.width
);
}
-24
View File
@@ -1,24 +0,0 @@
import { type Cell, cellsEqual } from "./cell.js";
import type { Screen } from "./screen.js";
export type DiffCallback = (
x: number,
y: number,
prev: Cell | undefined,
next: Cell | undefined,
) => boolean | undefined;
export function diffEach(prev: Screen, next: Screen, cb: DiffCallback): boolean {
const w = Math.max(prev.width, next.width);
const h = Math.max(prev.height, next.height);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const a = prev.cellAt(x, y);
const b = next.cellAt(x, y);
if (a === b) continue;
if (a !== undefined && b !== undefined && cellsEqual(a, b)) continue;
if (cb(x, y, a, b) === true) return true;
}
}
return false;
}
-59
View File
@@ -1,59 +0,0 @@
import { type Cell, EMPTY_CELL } from "./cell.js";
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
export class Screen {
readonly width: number;
readonly height: number;
readonly cells: Cell[];
private _damage: Rectangle | undefined;
constructor(width: number, height: number) {
this.width = Math.max(0, width | 0);
this.height = Math.max(0, height | 0);
this.cells = new Array<Cell>(this.width * this.height);
for (let i = 0; i < this.cells.length; i++) this.cells[i] = EMPTY_CELL;
this._damage = undefined;
}
get damage(): Rectangle | undefined {
return this._damage;
}
resetDamage(): void {
this._damage = undefined;
}
cellAt(x: number, y: number): Cell | undefined {
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return undefined;
return this.cells[y * this.width + x];
}
writeCell(x: number, y: number, cell: Cell): void {
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
this.cells[y * this.width + x] = cell;
this.markDamage(x, y, 1, 1);
}
markDamage(x: number, y: number, w: number, h: number): void {
if (w <= 0 || h <= 0) return;
if (this._damage === undefined) {
this._damage = { x, y, width: w, height: h };
return;
}
const d = this._damage;
const x1 = Math.min(d.x, x);
const y1 = Math.min(d.y, y);
const x2 = Math.max(d.x + d.width, x + w);
const y2 = Math.max(d.y + d.height, y + h);
d.x = x1;
d.y = y1;
d.width = x2 - x1;
d.height = y2 - y1;
}
}
+1
View File
@@ -15,6 +15,7 @@ export async function handleSlash(
summary: c.summary,
argsHint: c.argsHint,
contextual: c.contextual,
aliases: c.aliases,
}));
return { status: 200, body: { commands, codeMode } };
}
+120 -1
View File
@@ -1,6 +1,10 @@
/** web_search uses Mojeek (DDG returns anti-bot 202 to unauthenticated POSTs); web_fetch sniffs HTML to text. */
import { parse as parseHtml } from "node-html-parser";
import {
webSearchEndpoint as loadWebSearchEndpoint,
webSearchEngine as loadWebSearchEngine,
} from "../config.js";
import type { ToolRegistry } from "../tools.js";
export interface SearchResult {
@@ -28,6 +32,10 @@ export interface WebFetchOptions {
export interface WebSearchOptions {
topK?: number;
signal?: AbortSignal;
/** Backend engine: "mojeek" (scrapes Mojeek HTML) or "searxng" (self-hosted SearXNG JSON API). */
engine?: "mojeek" | "searxng";
/** Base URL for SearXNG. Default http://localhost:8080. */
endpoint?: string;
}
const DEFAULT_FETCH_MAX_CHARS = 32_000;
@@ -46,6 +54,13 @@ export async function webSearch(
query: string,
opts: WebSearchOptions = {},
): Promise<SearchResult[]> {
if (opts.engine === "searxng") {
return searchSearxng(query, opts);
}
return searchMojeek(query, opts);
}
async function searchMojeek(query: string, opts: WebSearchOptions = {}): Promise<SearchResult[]> {
const topK = Math.max(1, Math.min(10, opts.topK ?? DEFAULT_TOPK));
const resp = await fetch(`${MOJEEK_ENDPOINT}?q=${encodeURIComponent(query)}`, {
headers: {
@@ -71,6 +86,101 @@ export async function webSearch(
return results;
}
/** Parse + validate a SearXNG endpoint. Returns origin (protocol + host). */
function normalizeSearxngEndpoint(raw: string): string {
let url: URL;
try {
url = new URL(raw.includes("://") ? raw : `http://${raw}`);
} catch {
throw new Error(`web_search: invalid SearXNG endpoint "${raw}"`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`web_search: SearXNG endpoint must be http(s), got ${url.protocol}`);
}
return url.origin;
}
async function searchSearxng(query: string, opts: WebSearchOptions = {}): Promise<SearchResult[]> {
const topK = Math.max(1, Math.min(10, opts.topK ?? DEFAULT_TOPK));
const baseUrl = normalizeSearxngEndpoint(opts.endpoint ?? "http://localhost:8080");
// JSON API is often blocked by SearXNG's default limiter; HTML always works.
const url = `${baseUrl}/search?format=html&q=${encodeURIComponent(query)}`;
let resp: Response;
try {
resp = await fetch(url, {
headers: {
"User-Agent": USER_AGENT,
Accept: "text/html",
},
signal: opts.signal,
});
} catch (err) {
if (err instanceof TypeError && (err as Error).message.includes("fetch")) {
throw new Error(
`web_search: Cannot reach SearXNG server at ${opts.endpoint ?? "http://localhost:8080"}. Please install SearXNG (https://github.com/searxng/searxng) and start it (e.g. \`docker run -d -p 8080:8080 searxng/searxng\`), or switch to the default engine with /search-engine mojeek.`,
);
}
throw err;
}
if (!resp.ok) throw new Error(`web_search ${resp.status}`);
const html = await resp.text();
const results = parseSearxngHtmlResults(html).slice(0, topK);
if (results.length === 0) {
if (/no results found|did not match any documents/i.test(html)) return [];
throw new Error(
`web_search: 0 results but SearXNG response doesn't look like an empty results page (${html.length} chars)`,
);
}
return results;
}
/** Parse SearXNG HTML search results using node-html-parser. */
export function parseSearxngHtmlResults(html: string): SearchResult[] {
const root = parseHtml(html);
const results: SearchResult[] = [];
// Try <article class="result"> first (default SearXNG theme)
const articles = root.querySelectorAll("article.result, div.result");
if (articles.length > 0) {
for (const article of articles) {
const link = article.querySelector("h3 a, h4 a, a[href^='http']");
if (!link) continue;
const href = link.getAttribute("href");
if (!href) continue;
const title = link.textContent.trim();
if (!title) continue;
let snippet = "";
for (const p of article.querySelectorAll("p")) {
const text = p.textContent.trim();
if (text.length > 10 && !text.includes(title)) {
snippet = text;
break;
}
}
if (!snippet) {
const cs = article.querySelector(".content, .result-content, [class*='snippet']");
if (cs) snippet = cs.textContent.trim();
}
results.push({ title, url: href, snippet });
}
return results;
}
// Fallback: <h3><a href> pairs directly
for (const a of root.querySelectorAll("h3 a[href]")) {
const href = a.getAttribute("href");
if (!href || href.startsWith("#")) continue;
const title = a.textContent.trim();
if (!title) continue;
let snippet = "";
const p = a.parentNode?.parentNode?.querySelector("p");
if (p) snippet = p.textContent.trim();
results.push({ title, url: href, snippet });
}
return results;
}
/** Title-anchor + snippet-paragraph passes paired positionally — robust to attribute reorder. */
export function parseMojeekResults(html: string): SearchResult[] {
const titles: string[] = [];
@@ -283,6 +393,10 @@ export interface WebToolsOptions {
defaultTopK?: number;
/** Byte cap for `web_fetch` extracted text. */
maxFetchChars?: number;
/** Backend engine: "mojeek" (default, scrapes Mojeek) or "searxng" (self-hosted SearXNG). */
webSearchEngine?: "mojeek" | "searxng";
/** Base URL for SearXNG (default http://localhost:8080). */
webSearchEndpoint?: string;
}
export function registerWebTools(registry: ToolRegistry, opts: WebToolsOptions = {}): ToolRegistry {
@@ -292,7 +406,8 @@ export function registerWebTools(registry: ToolRegistry, opts: WebToolsOptions =
registry.register({
name: "web_search",
description:
"Search the public web. Returns ranked results with title, url, and snippet. Call this when the answer's correctness depends on current state — anything that changes over time (events, prices, releases, status of a thing in the real world). Composing such answers from training memory invents stale numbers; search first, then ground the answer in the results. For evergreen / definitional questions you don't need this.",
"Search the public web. Returns ranked results with title, url, and snippet. Call this when the answer's correctness depends on current state — anything that changes over time (events, prices, releases, status of a thing in the real world). Composing such answers from training memory invents stale numbers; search first, then ground the answer in the results. For evergreen / definitional questions you don't need this." +
" To change the backend, use /web-search-engine mojeek|searxng.",
readOnly: true,
parallelSafe: true,
parameters: {
@@ -307,9 +422,13 @@ export function registerWebTools(registry: ToolRegistry, opts: WebToolsOptions =
required: ["query"],
},
fn: async (args: { query: string; topK?: number }, ctx) => {
const engine = opts.webSearchEngine ?? loadWebSearchEngine();
const endpoint = opts.webSearchEndpoint ?? loadWebSearchEndpoint();
const results = await webSearch(args.query, {
topK: args.topK ?? defaultTopK,
signal: ctx?.signal,
engine,
endpoint,
});
return formatSearchResults(args.query, results);
},
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more