mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e93c4ffde3 | ||
|
|
0e4cba3670 | ||
|
|
d0b251d43a | ||
|
|
daebf3d571 | ||
|
|
28119cad01 | ||
|
|
3c24c0f1e1 | ||
|
|
7edcb07465 | ||
|
|
04b12e9f2d | ||
|
|
b07b86ee2f | ||
|
|
7ba3d086ff | ||
|
|
1a7d74f3f0 | ||
|
|
0f7dcd026a | ||
|
|
12998d246d | ||
|
|
43d437fd38 | ||
|
|
62813e3d6d | ||
|
|
a29a0ac157 | ||
|
|
ccdbfb3a28 | ||
|
|
1805b0c647 | ||
|
|
4032930fed | ||
|
|
b03e11bf41 | ||
|
|
0b91250c44 | ||
|
|
5bcc3b6389 | ||
|
|
0f7f7c24c2 | ||
|
|
6379bc0628 | ||
|
|
82c5627123 | ||
|
|
e991644416 |
No files matched your search
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
+265
@@ -3,6 +3,271 @@
|
||||
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.4] — 2026-05-07
|
||||
|
||||
**Headline:** sweep of the user-reported bug + onboarding queue from
|
||||
the 0.30.2 / 0.30.3 launch day. Resume now restores the full session
|
||||
state (cache hit %, cost, last context bar — previously they all
|
||||
showed zero on a fresh boot until the first turn landed). The model
|
||||
pill on assistant cards reflects the model that actually answered
|
||||
after `/model` or `/preset` switches it. Bare `/model` opens an
|
||||
interactive picker — typed-id entry stays for power users.
|
||||
PowerShell users get Shift+Tab back via three additional encodings
|
||||
(modifier-encoded back-tab, modifyOtherKeys, Kitty keyboard). And a
|
||||
class of "junk text after exit" on Linux/fish (terminal-feature
|
||||
replies leaking into the parent shell) gets a defensive stdin drain
|
||||
in the exit path.
|
||||
|
||||
`--dir` is now discoverable for beginners — surfaced in the welcome
|
||||
banner, the `/status` panel, the filesystem sandbox-escape error,
|
||||
and a Getting Started callout in both READMEs.
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(stats): persist cache totals + `lastPromptTokens` across
|
||||
resume. `SessionMeta` only carried `totalCostUsd` / `turnCount`,
|
||||
so on every resume `/status` showed 0 context + 0% cache hit until
|
||||
the first turn actually fired (even though the prefix was already
|
||||
cached, costing $0.01 per turn). Three new fields are persisted
|
||||
per-turn and seeded into `SessionStats` on resume; the existing
|
||||
carryover plumbing now covers cache + last context.
|
||||
(#364, PR #384)
|
||||
- fix(ui): `/model <id>` and `/preset {auto,flash,pro}` now update
|
||||
the active model in the agent store so the next assistant card
|
||||
pill reflects the new selection. Previously `state.session.model`
|
||||
was set once in `initialState()` and never mutated, so the pill
|
||||
showed the launch-time model regardless of what actually answered
|
||||
the turn. New `session.model.change` event; cards already opened
|
||||
keep their captured model so mid-turn auto-escalation doesn't
|
||||
retroactively relabel. (#372, PR #385)
|
||||
- fix(input): recognize three additional Shift+Tab encodings for
|
||||
PowerShell hosts and modern terminals — `\x1b[1;2Z` (modifier-
|
||||
encoded back-tab some PowerShell hosts emit), `\x1b[27;2;9~`
|
||||
(modifyOtherKeys level 2, which we already enable on startup),
|
||||
`\x1b[9;2u` (Kitty keyboard envelope). Without these the edit-
|
||||
mode cycle was silently dropped on PowerShell.
|
||||
`/mode` typed fallback continues to work. (#373, PR #386)
|
||||
- fix(tty): drain pending feature-detection replies on exit. Linux
|
||||
reporters saw `^[]11;rgb:...^[\^[[33;1R^[[?62;1;4c` printed by
|
||||
fish / bash after exiting reasonix — those bytes are responses to
|
||||
OSC 11 / CPR / DA1 queries the runtime emits during startup that
|
||||
sit in stdin's queue until exit. New `drainTtyResponses(50ms)`
|
||||
reads-and-discards anything queued before control returns to the
|
||||
parent shell. Layered on top of 0.30.3's alt-screen mitigation
|
||||
(`--no-alt-screen` users get the fix too). (#365, PR #391)
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(ui): bare `/model` opens an interactive model picker — arrow-
|
||||
key list, current model marked, `[r]` refreshes the catalog, esc
|
||||
cancels. Seeds from the live DeepSeek catalog
|
||||
(`useSessionInfo.listModels()`); falls back to the four known
|
||||
DeepSeek ids when the catalog hasn't loaded yet so the picker
|
||||
isn't empty on first open. The current id is always included even
|
||||
when the API didn't return it. `/model <id>` typed entry stays
|
||||
for power users. (#371, PR #387)
|
||||
- feat(ui): surface `--dir` / pinned workspace for first-time users.
|
||||
WelcomeBanner shows the workspace + relaunch hint in code mode;
|
||||
`/status` adds a `workspace <path> · pinned at launch` line; the
|
||||
filesystem sandbox-escape error points at `reasonix code --dir
|
||||
<path>` instead of just dropping a raw error; both READMEs gain a
|
||||
Getting Started subsection on `--dir`. No new slash command —
|
||||
mid-session retargeting is intentionally not supported (the
|
||||
message log + memory paths get tangled with stale roots).
|
||||
(#370, PR #389)
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/reasonix/">Website</a>
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">Architecture</a>
|
||||
·
|
||||
@@ -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
|
||||
@@ -52,6 +98,14 @@ npx reasonix code # paste a DeepSeek API key on first run; persists after
|
||||
|
||||
Requires Node ≥ 22. Tested on macOS · Linux · Windows (PowerShell · Git Bash · Windows Terminal). Get a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
|
||||
|
||||
**Working in a different folder:** Reasonix scopes filesystem tools to the launch directory. To work elsewhere, pass `--dir`:
|
||||
|
||||
```bash
|
||||
npx reasonix code --dir /path/to/project # or use a relative path
|
||||
```
|
||||
|
||||
Mid-session switching isn't supported by design (the message log + memory paths get tangled with stale roots). Quit and relaunch with a new `--dir` to retarget. `/status` always shows the current pinned workspace.
|
||||
|
||||
<br/>
|
||||
|
||||
## What makes Reasonix different
|
||||
@@ -87,8 +141,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 +155,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 +171,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:
|
||||
|
||||
+11
-3
@@ -7,7 +7,7 @@
|
||||
·
|
||||
<strong>简体中文</strong>
|
||||
·
|
||||
<a href="https://esengine.github.io/reasonix/">官方网站</a>
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">架构文档</a>
|
||||
·
|
||||
@@ -52,6 +52,14 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
|
||||
要求 Node ≥ 22。已在 macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)测过。[去拿 DeepSeek API Key →](https://platform.deepseek.com/api_keys) · 完整 flag 看 `reasonix code --help`。
|
||||
|
||||
**在其他目录工作:** Reasonix 把文件系统工具作用域绑定在启动目录。要在别的目录工作,传 `--dir`:
|
||||
|
||||
```bash
|
||||
npx reasonix code --dir /path/to/project # 也可以用相对路径
|
||||
```
|
||||
|
||||
中途切换工作区是有意不支持的(消息日志和 memory 路径会和旧的根目录混在一起,状态错乱)。退出后用新的 `--dir` 重新启动来切换。`/status` 始终显示当前锁定的工作区。
|
||||
|
||||
<br/>
|
||||
|
||||
## Reasonix 的不同之处
|
||||
@@ -99,7 +107,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 +123,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 |
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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)}%`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+429
-450
File diff suppressed because it is too large.
Load diff
+6
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.29.1",
|
||||
"version": "0.30.4",
|
||||
"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",
|
||||
|
||||
+34
-15
@@ -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";
|
||||
@@ -24,6 +31,7 @@ import { registerWebTools } from "../../tools/web.js";
|
||||
import { App } from "../ui/App.js";
|
||||
import { SessionPicker } from "../ui/SessionPicker.js";
|
||||
import { Setup } from "../ui/Setup.js";
|
||||
import { drainTtyResponses } from "../ui/drain-tty.js";
|
||||
import { KeystrokeProvider } from "../ui/keystroke-context.js";
|
||||
import { formatMcpLifecycleEvent } from "../ui/mcp-lifecycle.js";
|
||||
import { formatMcpSlowToast } from "../ui/mcp-toast.js";
|
||||
@@ -101,6 +109,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 +309,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 +460,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 +494,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,12 +506,23 @@ 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();
|
||||
} finally {
|
||||
await runtime.closeAll();
|
||||
// Eat any pending terminal-feature-detection responses (#365) so the
|
||||
// parent shell doesn't print them as junk after exit.
|
||||
await drainTtyResponses();
|
||||
}
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+144
-65
@@ -72,6 +72,7 @@ import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
|
||||
import { ChoiceConfirm, type ChoiceConfirmChoice } from "./ChoiceConfirm.js";
|
||||
import { EditConfirm, type EditReviewChoice } from "./EditConfirm.js";
|
||||
import { McpHub } from "./McpHub.js";
|
||||
import { ModelPicker } from "./ModelPicker.js";
|
||||
import { PlanCheckpointConfirm } from "./PlanCheckpointConfirm.js";
|
||||
import { PlanConfirm, type PlanConfirmChoice } from "./PlanConfirm.js";
|
||||
import { PlanRefineInput } from "./PlanRefineInput.js";
|
||||
@@ -96,6 +97,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 +106,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 +129,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 +301,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 +370,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 +411,7 @@ function AppInner({
|
||||
undoBanner,
|
||||
recordEdit,
|
||||
armUndoBanner,
|
||||
toggleUndoPause,
|
||||
codeUndo,
|
||||
codeHistory,
|
||||
codeShowEdit,
|
||||
@@ -547,6 +526,8 @@ function AppInner({
|
||||
const [sessionsPickerList, setSessionsPickerList] = useState<ReturnType<typeof listSessions>>([]);
|
||||
/** Opens the unified McpHub modal — null when closed. `tab` selects the initial tab. */
|
||||
const [pendingMcpHub, setPendingMcpHub] = useState<{ tab: "live" | "marketplace" } | null>(null);
|
||||
/** True while the ModelPicker is open mid-chat (triggered by bare `/model`). */
|
||||
const [pendingModelPicker, setPendingModelPicker] = useState(false);
|
||||
// Stashed plan + intent while the user types free-form feedback
|
||||
// (refinement or last instructions on approve). When the picker
|
||||
// returns "refine" or "approve", we defer the loop-resume and show
|
||||
@@ -602,6 +583,23 @@ 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 ||
|
||||
pendingModelPicker ||
|
||||
!!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 +1150,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 +1266,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 +1299,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
|
||||
@@ -2100,6 +2156,11 @@ function AppInner({
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openModelPicker) {
|
||||
setPendingModelPicker(true);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openArgPickerFor) {
|
||||
pushHistory(text);
|
||||
setInput(`/${result.openArgPickerFor} `);
|
||||
@@ -2359,7 +2420,18 @@ 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;
|
||||
const u = ev.stats?.usage;
|
||||
const cacheHitTokens = (m.cacheHitTokens ?? 0) + (u?.promptCacheHitTokens ?? 0);
|
||||
const cacheMissTokens = (m.cacheMissTokens ?? 0) + (u?.promptCacheMissTokens ?? 0);
|
||||
patchSessionMeta(session, {
|
||||
totalCostUsd: cost,
|
||||
turnCount: turn,
|
||||
cacheHitTokens,
|
||||
cacheMissTokens,
|
||||
...(u?.promptTokens ? { lastPromptTokens: u.promptTokens } : {}),
|
||||
...(currency ? { balanceCurrency: currency } : {}),
|
||||
});
|
||||
}
|
||||
} else if (ev.role === "tool_start") {
|
||||
handleToolStart(ev, {
|
||||
@@ -3003,40 +3075,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
|
||||
@@ -3046,6 +3103,7 @@ function AppInner({
|
||||
{!hasConversation && !busy && !isStreaming ? (
|
||||
<WelcomeBanner
|
||||
inCodeMode={!!codeMode}
|
||||
workspaceRoot={codeMode ? currentRootDir : undefined}
|
||||
dashboardUrl={dashboardUrl}
|
||||
languageVersion={languageVersion}
|
||||
/>
|
||||
@@ -3190,6 +3248,7 @@ function AppInner({
|
||||
<SessionPicker
|
||||
sessions={sessionsPickerList}
|
||||
workspace={currentRootDir}
|
||||
walletCurrency={walletCurrencyRef.current}
|
||||
onChoose={(outcome) => {
|
||||
if (outcome.kind === "open") {
|
||||
setPendingSessionsPicker(false);
|
||||
@@ -3228,6 +3287,20 @@ function AppInner({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : pendingModelPicker ? (
|
||||
<ModelPicker
|
||||
models={models}
|
||||
current={loop.model}
|
||||
onRefresh={refreshModels}
|
||||
onChoose={(outcome) => {
|
||||
setPendingModelPicker(false);
|
||||
if (outcome.kind === "select") {
|
||||
loop.configure({ model: outcome.id });
|
||||
agentStore.dispatch({ type: "session.model.change", model: outcome.id });
|
||||
log.pushInfo(`▸ model: ${outcome.id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : pendingMcpHub ? (
|
||||
<McpHub
|
||||
initialTab={pendingMcpHub.tab}
|
||||
@@ -3304,6 +3377,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 +3402,8 @@ function AppInner({
|
||||
disabled={busy}
|
||||
onHistoryPrev={recallPrev}
|
||||
onHistoryNext={recallNext}
|
||||
onChatScrollUp={chatScroll.scrollUp}
|
||||
onChatScrollDown={chatScroll.scrollDown}
|
||||
/>
|
||||
{slashMatches !== null ? (
|
||||
<SlashSuggestions matches={slashMatches} selectedIndex={slashSelected} />
|
||||
|
||||
@@ -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,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,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,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 {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Box, Text, useStdout } 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 { PILL_MODEL, Pill, modelBadgeFor } from "./primitives/Pill.js";
|
||||
import { FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type ModelPickerOutcome = { kind: "select"; id: string } | { kind: "quit" };
|
||||
|
||||
export interface ModelPickerProps {
|
||||
/** API-fetched ids; null means "still loading / offline". */
|
||||
models: ReadonlyArray<string> | null;
|
||||
/** Model id currently active in the loop — marked with the cursor on open. */
|
||||
current: string;
|
||||
onChoose: (outcome: ModelPickerOutcome) => void;
|
||||
/** Triggers a refetch when the catalog is null/empty and the user presses [r]. */
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 6;
|
||||
|
||||
export function ModelPicker({
|
||||
models,
|
||||
current,
|
||||
onChoose,
|
||||
onRefresh,
|
||||
}: ModelPickerProps): React.ReactElement {
|
||||
const list = (models && models.length > 0 ? models : FALLBACK_MODELS).slice();
|
||||
if (!list.includes(current)) list.unshift(current);
|
||||
const initialIndex = Math.max(0, list.indexOf(current));
|
||||
const [focus, setFocus] = useState(initialIndex);
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.escape) return onChoose({ kind: "quit" });
|
||||
if (ev.upArrow) return setFocus((f) => Math.max(0, f - 1));
|
||||
if (ev.downArrow) return setFocus((f) => Math.min(list.length - 1, f + 1));
|
||||
if (ev.return) {
|
||||
const target = list[focus];
|
||||
if (target) onChoose({ kind: "select", id: target });
|
||||
return;
|
||||
}
|
||||
if (!ev.input) return;
|
||||
if (ev.input === "q") return onChoose({ kind: "quit" });
|
||||
if (ev.input === "r") onRefresh?.();
|
||||
});
|
||||
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(focus - Math.floor(visibleCount / 2), list.length - visibleCount),
|
||||
);
|
||||
const end = Math.min(list.length, start + visibleCount);
|
||||
const shown = list.slice(start, end);
|
||||
const hiddenAbove = start;
|
||||
const hiddenBelow = list.length - end;
|
||||
const loading = models === null;
|
||||
const empty = models !== null && models.length === 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text bold color={TONE.brand}>
|
||||
{" ◈ REASONIX · pick a model "}
|
||||
</Text>
|
||||
<Text color={FG.meta}>
|
||||
{loading
|
||||
? " · loading catalog…"
|
||||
: empty
|
||||
? " · catalog empty — using known fallbacks"
|
||||
: ` · ${list.length} available`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
{hiddenAbove > 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` … ${hiddenAbove} earlier`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{shown.map((id, i) => (
|
||||
<ModelRow key={id} id={id} focused={start + i === focus} active={id === current} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>{" ↑↓ pick · ⏎ confirm · [r] refresh · esc cancel"}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
id,
|
||||
focused,
|
||||
active,
|
||||
}: {
|
||||
id: string;
|
||||
focused: boolean;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const badge = modelBadgeFor(id);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
|
||||
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
|
||||
{id.padEnd(24)}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Pill label={badge.label} {...PILL_MODEL[badge.kind]} bold={false} />
|
||||
{active ? <Text color={TONE.brand}>{" · current"}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hard-coded known DeepSeek ids — used when the API catalog hasn't loaded yet so the picker isn't empty on first open. */
|
||||
const FALLBACK_MODELS: ReadonlyArray<string> = [
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
];
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { FG, TONE } from "./theme/tokens.js";
|
||||
export interface WelcomeBannerProps {
|
||||
/** True when running `reasonix code`. Surfaces code-mode hints. */
|
||||
inCodeMode?: boolean;
|
||||
/** Pinned workspace root — only meaningful in code mode. Surfaced so first-time users see they can pass --dir at next launch. */
|
||||
workspaceRoot?: string;
|
||||
/** Live URL of the embedded dashboard, or null when it isn't running. */
|
||||
dashboardUrl?: string | null;
|
||||
/** Bumped on language change; forces re-render so t() picks up new locale. */
|
||||
@@ -19,6 +21,7 @@ const HINTS = ["/help", "/init", "/memory", "/cost"] as const;
|
||||
|
||||
export function WelcomeBanner({
|
||||
inCodeMode,
|
||||
workspaceRoot,
|
||||
dashboardUrl,
|
||||
}: WelcomeBannerProps): React.ReactElement {
|
||||
const tagline = inCodeMode ? t("ui.taglineCode") : t("ui.taglineChat");
|
||||
@@ -66,6 +69,15 @@ export function WelcomeBanner({
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{inCodeMode && workspaceRoot ? (
|
||||
<Box marginTop={1} flexDirection="row" gap={1}>
|
||||
<Text color={TONE.brand}>{"▸ workspace"}</Text>
|
||||
<Text color={FG.faint}>{"·"}</Text>
|
||||
<Text color={FG.body}>{workspaceRoot}</Text>
|
||||
<Text color={FG.faint}>{" (relaunch with --dir <path> to switch)"}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{dashboardUrl ? (
|
||||
<Box marginTop={1} flexDirection="row" gap={1}>
|
||||
<Text color={TONE.brand} bold>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}, []);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** stdin-queue drain on exit — eats stuck terminal-feature-detection responses (#365). */
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
/** Eats stuck OSC/CPR/DA replies on exit so fish/bash don't print them as input (#365). */
|
||||
export async function drainTtyResponses(timeoutMs = 50): Promise<void> {
|
||||
const stdin = process.stdin;
|
||||
if (!stdin.isTTY && typeof (stdin as { setRawMode?: unknown }).setRawMode !== "function") {
|
||||
return;
|
||||
}
|
||||
let raised = false;
|
||||
try {
|
||||
stdin.setRawMode(true);
|
||||
raised = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
stdin.resume();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const onData = (_chunk: Buffer | string): void => {
|
||||
// Discard — anything pending here is a terminal-feature reply.
|
||||
};
|
||||
stdin.on("data", onData);
|
||||
const timer = setTimeout(() => {
|
||||
stdin.off("data", onData);
|
||||
stdin.pause();
|
||||
if (raised) {
|
||||
try {
|
||||
stdin.setRawMode(false);
|
||||
} catch {
|
||||
/* stdin may already be closed; ignore */
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
timer.unref();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -27,7 +27,13 @@ const CSI_TAIL_TO_FLAGS: ReadonlyArray<{ tail: string; flags: CsiKeyFlags }> = [
|
||||
// Forward-delete (the key labelled Delete on most keyboards).
|
||||
{ tail: "[3~", flags: { delete: true } },
|
||||
// Shift+Tab — terminal sends `\x1b[Z` rather than tab-with-shift.
|
||||
// `[1;2Z` is the modifier-encoded variant some Windows PowerShell
|
||||
// hosts emit; `[27;2;9~` and `[9;2u` cover modifyOtherKeys / Kitty
|
||||
// forms. Issue #373.
|
||||
{ tail: "[Z", flags: { shift: true, tab: true } },
|
||||
{ tail: "[1;2Z", flags: { shift: true, tab: true } },
|
||||
{ tail: "[27;2;9~", flags: { shift: true, tab: true } },
|
||||
{ tail: "[9;2u", flags: { shift: true, tab: true } },
|
||||
];
|
||||
|
||||
function alreadyStructured(flags: CsiKeyFlags): boolean {
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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";
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)` };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -7,10 +7,10 @@ const model: SlashHandler = (args, loop, ctx) => {
|
||||
const id = args[0];
|
||||
const known = ctx.models ?? null;
|
||||
if (!id) {
|
||||
const hint = known && known.length > 0 ? known.join(" | ") : t("handlers.model.modelHint");
|
||||
return { info: t("handlers.model.modelUsage", { hint }) };
|
||||
return { openModelPicker: true };
|
||||
}
|
||||
loop.configure({ model: id });
|
||||
ctx.dispatch?.({ type: "session.model.change", model: id });
|
||||
if (known && known.length > 0 && !known.includes(id)) {
|
||||
return {
|
||||
info: t("handlers.model.modelNotInCatalog", { id, list: known.join(", ") }),
|
||||
@@ -53,7 +53,7 @@ const harvest: SlashHandler = (args, loop) => {
|
||||
return { info: t("handlers.model.harvestOff") };
|
||||
};
|
||||
|
||||
const preset: SlashHandler = (args, loop) => {
|
||||
const preset: SlashHandler = (args, loop, ctx) => {
|
||||
const name = (args[0] ?? "").toLowerCase();
|
||||
const applyAndPersist = (effort: "high" | "max") => {
|
||||
try {
|
||||
@@ -62,8 +62,7 @@ const preset: SlashHandler = (args, loop) => {
|
||||
/* disk full / perms — runtime change still took effect */
|
||||
}
|
||||
};
|
||||
if (name === "auto") {
|
||||
const p = PRESETS.auto;
|
||||
const apply = (p: (typeof PRESETS)[keyof typeof PRESETS]) => {
|
||||
loop.configure({
|
||||
model: p.model,
|
||||
autoEscalate: p.autoEscalate,
|
||||
@@ -71,31 +70,19 @@ const preset: SlashHandler = (args, loop) => {
|
||||
harvest: p.harvest,
|
||||
branch: p.branch,
|
||||
});
|
||||
ctx.dispatch?.({ type: "session.model.change", model: p.model });
|
||||
applyAndPersist(p.reasoningEffort);
|
||||
};
|
||||
if (name === "auto") {
|
||||
apply(PRESETS.auto);
|
||||
return { info: t("handlers.model.presetAuto") };
|
||||
}
|
||||
if (name === "flash") {
|
||||
const p = PRESETS.flash;
|
||||
loop.configure({
|
||||
model: p.model,
|
||||
autoEscalate: p.autoEscalate,
|
||||
reasoningEffort: p.reasoningEffort,
|
||||
harvest: p.harvest,
|
||||
branch: p.branch,
|
||||
});
|
||||
applyAndPersist(p.reasoningEffort);
|
||||
apply(PRESETS.flash);
|
||||
return { info: t("handlers.model.presetFlash") };
|
||||
}
|
||||
if (name === "pro") {
|
||||
const p = PRESETS.pro;
|
||||
loop.configure({
|
||||
model: p.model,
|
||||
autoEscalate: p.autoEscalate,
|
||||
reasoningEffort: p.reasoningEffort,
|
||||
harvest: p.harvest,
|
||||
branch: p.branch,
|
||||
});
|
||||
applyAndPersist(p.reasoningEffort);
|
||||
apply(PRESETS.pro);
|
||||
return { info: t("handlers.model.presetPro") };
|
||||
}
|
||||
return { info: t("handlers.model.presetUsage") };
|
||||
|
||||
@@ -132,6 +132,9 @@ const status: SlashHandler = (_args, loop, ctx) => {
|
||||
: "";
|
||||
const dashUrl = ctx.getDashboardUrl?.();
|
||||
const dashLine = dashUrl ? t("handlers.observability.statusDash", { url: dashUrl }) : "";
|
||||
const workspaceLine = ctx.codeRoot
|
||||
? t("handlers.observability.statusWorkspace", { path: ctx.codeRoot })
|
||||
: "";
|
||||
const lines = [
|
||||
t("handlers.observability.statusModel", { model: loop.model }),
|
||||
t("handlers.observability.statusFlags", {
|
||||
@@ -145,6 +148,7 @@ const status: SlashHandler = (_args, loop, ctx) => {
|
||||
mcpLine,
|
||||
sessionLine,
|
||||
];
|
||||
if (workspaceLine) lines.push(workspaceLine);
|
||||
if (budgetLine) lines.push(budgetLine);
|
||||
if (pendingLine) lines.push(pendingLine);
|
||||
if (planLine) lines.push(planLine);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -10,6 +10,8 @@ export interface SlashResult {
|
||||
info?: string;
|
||||
/** Open the SessionPicker modal mid-chat — used by `/sessions` slash. */
|
||||
openSessionsPicker?: boolean;
|
||||
/** Open the ModelPicker modal — bare `/model` (no id) opens it. */
|
||||
openModelPicker?: boolean;
|
||||
/** Open the unified MCP hub — `/mcp` defaults to "live", `/mcp browse` to "marketplace". */
|
||||
openMcpHub?: { tab: "live" | "marketplace" };
|
||||
/** Open the arg-completer picker for this command (e.g. `/language` → language picker). */
|
||||
@@ -132,6 +134,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 {
|
||||
|
||||
@@ -122,6 +122,11 @@ const sessionUpdate = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionModelChange = z.object({
|
||||
type: z.literal("session.model.change"),
|
||||
model: z.string().min(1),
|
||||
});
|
||||
|
||||
const focusMove = z.object({
|
||||
type: z.literal("focus.move"),
|
||||
direction: z.enum(["next", "prev", "first", "last"]),
|
||||
@@ -318,6 +323,7 @@ export const AgentEventSchema = z.discriminatedUnion("type", [
|
||||
networkChange,
|
||||
languageChange,
|
||||
sessionUpdate,
|
||||
sessionModelChange,
|
||||
focusMove,
|
||||
focusSet,
|
||||
cardToggle,
|
||||
|
||||
@@ -116,6 +116,11 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
|
||||
case "session.update":
|
||||
return { ...state, status: { ...state.status, ...event.patch } };
|
||||
|
||||
case "session.model.change":
|
||||
return state.session.model === event.model
|
||||
? state
|
||||
: { ...state, session: { ...state.session, model: event.model } };
|
||||
|
||||
case "focus.move":
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -62,17 +62,23 @@ const CSI_TAIL_MAP: ReadonlyArray<{ tail: string; ev: KeyEvent }> = [
|
||||
{ tail: "6~", ev: { input: "", pageDown: true } },
|
||||
{ tail: "3~", ev: { input: "", delete: true } },
|
||||
{ tail: "Z", ev: { input: "", shift: true, tab: true } },
|
||||
// modifyOtherKeys (xterm CSI > 4 ; 2 m) sequences for Enter with
|
||||
// modifiers. Only fired when App.tsx has enabled the mode at
|
||||
// Some Windows hosts (PowerShell 7.x conhost path) emit the
|
||||
// modifier-encoded back-tab `\x1b[1;2Z` instead of bare `\x1b[Z`.
|
||||
// Issue #373 — without this entry Shift+Tab is silently dropped.
|
||||
{ tail: "1;2Z", ev: { input: "", shift: true, tab: true } },
|
||||
// modifyOtherKeys (xterm CSI > 4 ; 2 m) sequences for Enter / Tab
|
||||
// with modifiers. Only fired when App.tsx has enabled the mode at
|
||||
// startup; otherwise Shift+Enter stays indistinguishable from Enter.
|
||||
// Modifier encoding: 2=shift, 3=alt, 4=alt+shift, 5=ctrl,
|
||||
// 6=ctrl+shift, 7=ctrl+alt, 8=ctrl+alt+shift. Keycode 13 = Enter.
|
||||
// 6=ctrl+shift, 7=ctrl+alt, 8=ctrl+alt+shift. Keycodes: 9=Tab, 13=Enter.
|
||||
{ tail: "27;2;9~", ev: { input: "", tab: true, shift: true } },
|
||||
{ tail: "27;2;13~", ev: { input: "", return: true, shift: true } },
|
||||
{ tail: "27;5;13~", ev: { input: "", return: true, ctrl: true } },
|
||||
{ tail: "27;6;13~", ev: { input: "", return: true, ctrl: true, shift: true } },
|
||||
// Kitty keyboard protocol — same idea, different envelope:
|
||||
// `\x1b[<keycode>;<mod>u`. Some terminals (kitty, recent Windows
|
||||
// Terminal previews) prefer this shape. Harmless to map here too.
|
||||
{ tail: "9;2u", ev: { input: "", tab: true, shift: true } },
|
||||
{ tail: "13;2u", ev: { input: "", return: true, shift: true } },
|
||||
{ tail: "13;5u", ev: { input: "", return: true, ctrl: true } },
|
||||
{ tail: "13;6u", ev: { input: "", return: true, ctrl: true, shift: true } },
|
||||
|
||||
+32
-52
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
+3
-1
@@ -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:
|
||||
@@ -710,6 +710,8 @@ export const EN: TranslationSchema = {
|
||||
statusBudget: " budget ${spent} / ${cap} ({pct}%){tag}",
|
||||
statusSession: ' session "{name}" · {count} messages in log (resumed {resumed})',
|
||||
statusSessionEphemeral: " session (ephemeral — no persistence)",
|
||||
statusWorkspace:
|
||||
" workspace {path} · pinned at launch (relaunch with --dir <path> to switch)",
|
||||
statusMcp: " mcp {servers} server(s), {tools} tool(s) in registry",
|
||||
statusEdits: " edits {count} pending (/apply to commit, /discard to drop)",
|
||||
statusPlan: " plan ON — writes gated (submit_plan + approval)",
|
||||
|
||||
+2
-1
@@ -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: " 以便模型在下一轮看到。无允许列表限制。",
|
||||
@@ -653,6 +653,7 @@ export const zhCN: TranslationSchema = {
|
||||
statusBudget: " 预算 ${spent} / ${cap}({pct}%){tag}",
|
||||
statusSession: ' 会话 "{name}" · 日志中 {count} 条消息(恢复了 {resumed} 条)',
|
||||
statusSessionEphemeral: " 会话 (临时 — 无持久化)",
|
||||
statusWorkspace: " 工作区 {path} · 启动时锁定(用 --dir <path> 重新启动以切换)",
|
||||
statusMcp: " MCP {servers} 个服务器,注册表中 {tools} 个工具",
|
||||
statusEdits: " 编辑 {count} 个待处理(/apply 提交,/discard 丢弃)",
|
||||
statusPlan: " 计划 开启 — 写入受限(submit_plan + 审批)",
|
||||
|
||||
@@ -117,6 +117,7 @@ export {
|
||||
formatSearchResults,
|
||||
htmlToText,
|
||||
parseMojeekResults,
|
||||
parseSearxngHtmlResults,
|
||||
registerWebTools,
|
||||
webFetch,
|
||||
webSearch,
|
||||
|
||||
@@ -252,6 +252,9 @@ export class CacheFirstLoop {
|
||||
this.stats.seedCarryover({
|
||||
totalCostUsd: meta.totalCostUsd,
|
||||
turnCount: meta.turnCount,
|
||||
cacheHitTokens: meta.cacheHitTokens,
|
||||
cacheMissTokens: meta.cacheMissTokens,
|
||||
lastPromptTokens: meta.lastPromptTokens,
|
||||
});
|
||||
}
|
||||
if (healedCount > 0) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,13 @@ 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;
|
||||
/** Cumulative cache hit / miss tokens across the session — survives resume so /status cache% isn't 0 on a fresh boot. */
|
||||
cacheHitTokens?: number;
|
||||
cacheMissTokens?: number;
|
||||
/** Last turn's promptTokens — lets /status render the context bar before the next turn fires. */
|
||||
lastPromptTokens?: number;
|
||||
}
|
||||
|
||||
export function sessionsDir(): string {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user