mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f422a94dfa | ||
|
|
ee5961fe70 | ||
|
|
15b4060e68 | ||
|
|
9ec5b99e07 | ||
|
|
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 | ||
|
|
39dbe19bcd | ||
|
|
c1e8a933d3 | ||
|
|
b401913910 | ||
|
|
5662b57a4b | ||
|
|
e40e1911cc | ||
|
|
fdea3bb837 | ||
|
|
3efa4cc374 | ||
|
|
26ca84dd8f | ||
|
|
e91d136dbc | ||
|
|
12a3dab7e9 | ||
|
|
deefafed10 | ||
|
|
882a7ad2d9 | ||
|
|
b248a87455 | ||
|
|
ff858ed744 | ||
|
|
a5adca41ac | ||
|
|
aed4d431cb | ||
|
|
0acfcefb89 | ||
|
|
9cb0bdbc5f | ||
|
|
2ca403670d | ||
|
|
c6e5bf4b02 | ||
|
|
08c7f2be81 | ||
|
|
8befb44e5e | ||
|
|
b65ed747c9 | ||
|
|
86be00299e | ||
|
|
b634b5e171 | ||
|
|
3e91082456 | ||
|
|
638eaeeb46 | ||
|
|
1a7b88c72f | ||
|
|
6ee2428802 | ||
|
|
febecb3d6f | ||
|
|
795a1e2f35 | ||
|
|
77cd4f7f66 | ||
|
|
3ec46356f5 | ||
|
|
825c6e9608 | ||
|
|
affa03b235 | ||
|
|
4ba0199c42 | ||
|
|
5f80b252e8 | ||
|
|
4b77671103 | ||
|
|
900eae2a3a | ||
|
|
94047eadf3 | ||
|
|
0945a1189e | ||
|
|
bba8199ba4 | ||
|
|
fc8639a697 | ||
|
|
8c51dd6f0f | ||
|
|
56f1cbb835 | ||
|
|
abb1b1ec7a | ||
|
|
4f1e65a629 | ||
|
|
7096e43895 | ||
|
|
8543c88034 | ||
|
|
bdfb04c52b |
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
|
||||
|
||||
+498
@@ -3,6 +3,504 @@
|
||||
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.5] — 2026-05-07
|
||||
|
||||
**Headline:** three contributor-led follow-ups from the #350 RFC plus
|
||||
the #366 onboarding piece. The repeat-loop storm guard now exempts
|
||||
obviously-safe inspector tools (`read_file`, `list_directory`,
|
||||
`job_output`, `list_jobs`) so a model intentionally re-reading state
|
||||
isn't flagged as stuck. A new `wait_for_job(jobId, timeoutMs?)` tool
|
||||
replaces N-iteration polling loops with a single blocking call —
|
||||
returns the moment the job exits or emits new output. And `/skill
|
||||
new <name>` finally provides the missing creation entry-point for
|
||||
user skills, scaffolding a stub with the right frontmatter so
|
||||
first-time users don't have to read the source to author a skill.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(storm): add `stormExempt` flag on `ToolDefinition`, set on
|
||||
`read_file`, `list_directory`, `job_output`, `list_jobs`. Cheap
|
||||
state-inspection no longer trips the repeat-loop guard. Mutating
|
||||
tools and unknown tools still go through the existing window-and-
|
||||
threshold check. (#350, PR #388 by @ctharvey)
|
||||
- feat(jobs): new `wait_for_job(jobId, timeoutMs?)` shell tool —
|
||||
blocks until the job exits or emits new output, bounded by
|
||||
`timeoutMs` (default 5000, clamped to 0..30000). Returns
|
||||
`{ exited, exitCode, latestOutput }`; `latestOutput` is the
|
||||
delta since the call started, not the full buffer. Rides the
|
||||
existing job registry's exit + output events; one call replaces
|
||||
N polling iterations and is token-cheaper than the prior
|
||||
re-call-job_output loop. (#350, PR #390 by @ctharvey)
|
||||
- feat(skills): `/skill new <name>` scaffolds a stub at
|
||||
`<project>/.reasonix/skills/<name>.md` with minimal frontmatter
|
||||
+ a comment block listing the optional knobs (`runAs`,
|
||||
`allowed-tools`, `model`). `/skill new <name> --global` writes
|
||||
under `~/.reasonix/skills/` for cross-project use; auto-falls-
|
||||
back to global when there's no project root. The empty
|
||||
`/skill list` now ends with an explicit "no remote registry yet
|
||||
— scaffold one with `/skill new <name>`" line so users don't
|
||||
hunt for a marketplace that doesn't exist. (#366, PR #394)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(skills): atomic create with `wx` flag — close the TOCTOU
|
||||
race between `existsSync(...)` and `writeFileSync(...)` that
|
||||
CodeQL flagged. The existence check IS the atomic write now;
|
||||
`EEXIST` from a parallel writer surfaces as the same "skill
|
||||
already exists" error instead of silently overwriting. (PR #394)
|
||||
|
||||
## [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.
|
||||
The markdown renderer no longer turns English abbreviations like
|
||||
`e.g.` into broken hyperlinks (which on cmd.exe / non-OSC-8 terminals
|
||||
showed up as visible `]8;;file://e.g…` garbage and on the cell-diff
|
||||
side desynced the renderer's prev-frame model). The cell-diff
|
||||
renderer now defensively trail-clears any row whose content shrank
|
||||
between frames. Resumed sessions keep their cumulative session cost
|
||||
instead of resetting to `$0`. The Approve plan modal now shows the
|
||||
plan body inline when the model didn't supply structured steps.
|
||||
Wide markdown tables fall back to row-grouped key/value lines
|
||||
instead of the previous column-grouped output.
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(markdown): stop linkifying English abbreviations + drop OSC 8
|
||||
escape emission. The `FILE_REF_RE` extension class was too loose
|
||||
(`{1,6}`), so `e.g`, `i.e`, `a.m` matched as file paths; `osc8()`
|
||||
baked OSC 8 escape bytes into Text content, which the cell-diff
|
||||
renderer's wrapLine stripped of zero-width chars but kept the
|
||||
printable body — producing visible `]8;;file://e.ge.g]8;;` garbage
|
||||
on every terminal. Tightened the regex (now requires path-shape,
|
||||
line-number suffix, or extension >= 2 chars) and removed the OSC 8
|
||||
escape — file refs still stand out via color + underline. (#330,
|
||||
PR #341)
|
||||
- fix(renderer): trail-clear rows that shrank between frames in the
|
||||
cell-diff diff. The diff skipped cells where prev and next were
|
||||
byte-equal (including trailing EMPTY cells), so any earlier ANSI
|
||||
desync left stale chars in shrunken rows — manifested as the
|
||||
shell-confirm modal showing `allow always` + `mand, ask again next
|
||||
time` after Up/Down navigation. New `clearToEOL` patch type and a
|
||||
per-row sweep after `diffEach`. (#330, PR #341)
|
||||
- fix(stats): carry session cost / turn count across resume. The
|
||||
TUI's `$X session` figure reset to `$0` on every resume even
|
||||
though the disk meta still held the cumulative `totalCostUsd`.
|
||||
`SessionStats` gains `seedCarryover()`; `CacheFirstLoop` reads the
|
||||
meta on resume and seeds the carryover when prior messages exist.
|
||||
(#333, PR #342)
|
||||
- fix(plan): show the plan body in the Approve plan modal. When the
|
||||
model called `submit_plan` with a markdown body but no structured
|
||||
`steps`, the modal showed only the choice list — users had no way
|
||||
to see what they were approving without scrolling back. The modal
|
||||
now renders the body via `MarkdownView`, capped at 24 lines with
|
||||
an overflow hint. (#336, PR #343)
|
||||
- fix(markdown): row-group the table fallback layout. When a table
|
||||
was too wide for the viewport, the fallback flattened it as N
|
||||
"Component:" lines, then N "What:" lines, then N "Manual TCs:"
|
||||
lines — the reader couldn't tell which value belonged to which
|
||||
row. Swapped to row-first iteration with a blank separator
|
||||
between rows. (#340, PR #344)
|
||||
|
||||
## [0.29.0] — 2026-05-06
|
||||
|
||||
**Headline:** tool dispatch is no longer strictly serial. When the model
|
||||
emits multiple `parallelSafe`-annotated tool calls in one turn (multiple
|
||||
`read_file`, multiple `spawn_subagent`, etc.), the loop now races them
|
||||
together via `Promise.allSettled`; a non-`parallelSafe` call ends the
|
||||
chunk and runs alone, so read-after-write ordering still holds. Tool
|
||||
yields and history append still land in declared order regardless of
|
||||
which call settles first — the model and UI see the same shape they
|
||||
would under serial dispatch. The TUI's `SubagentRow` becomes
|
||||
`SubagentLiveStack`, rendering 1 → rich card, 2..max → compact rows,
|
||||
> max → "+N more running…" fold. Closes umbrella #325.
|
||||
|
||||
**Tool dispatch:**
|
||||
|
||||
- feat(tools): `ToolDefinition.parallelSafe?: boolean` — opt-in
|
||||
annotation, default `false`. `ToolRegistry.isParallelSafe(name)` for
|
||||
the dispatcher to query; unknown / unannotated tools resolve to
|
||||
`false` so third-party MCP tools must explicitly opt in. Built-in
|
||||
read-only filesystem (`read_file`, `list_directory`,
|
||||
`directory_tree`, `search_files`, `search_content`,
|
||||
`get_file_info`), web (`web_search`, `web_fetch`), `recall_memory`,
|
||||
`semantic_search`, isolated child loops (`run_skill`,
|
||||
`spawn_subagent`), and in-memory job queries (`job_output`,
|
||||
`list_jobs`) are annotated. Mutating tools stay default. (PR #326)
|
||||
- feat(loop): chunked parallel tool dispatch. Replaces `for...of +
|
||||
await` in the dispatch loop with a chunking loop that groups
|
||||
consecutive `parallelSafe` calls and races them; unsafe calls form
|
||||
serial barriers. `runOneToolCall` extracts per-call lifecycle
|
||||
(PreToolUse + dispatch + PostToolUse) so the chunk can fan out via
|
||||
`Promise.allSettled` while the loop body keeps yielding events in
|
||||
declared order. Two new env knobs: `REASONIX_PARALLEL_MAX` (chunk
|
||||
size cap, default 3, hard max 16) and `REASONIX_TOOL_DISPATCH=serial`
|
||||
(escape hatch). Tests cover parallel timing, serial barrier on mixed
|
||||
safe/unsafe, declared-order yields under racey completion, and both
|
||||
env-knob overrides. (PR #327)
|
||||
|
||||
**TUI:**
|
||||
|
||||
- feat(ui): `SubagentEvent` carries a stable `runId` per spawn so the
|
||||
sink can key concurrent runs apart instead of overwriting one shared
|
||||
row. `useSubagent` keeps an array of in-flight activities;
|
||||
`SubagentLiveStack` renders 1 → rich card (unchanged), 2..max →
|
||||
compact rows with per-row spinner + iter + last tool, > max →
|
||||
compact rows + "+N more running…" fold. (PR #327)
|
||||
|
||||
**Docs:**
|
||||
|
||||
- docs(architecture): `docs/ARCHITECTURE.md` Pillar 1 gains a
|
||||
"Parallel tool dispatch" section explaining the chunking rule, both
|
||||
env knobs, and the list of built-in tools that opt in. (PR #328)
|
||||
|
||||
## [0.28.0] — 2026-05-06
|
||||
|
||||
**Headline:** subagent capability sharpened on three axes — skills can
|
||||
now scope a child to a specific tool subset via `allowed-tools`
|
||||
frontmatter, callers can request a per-spawn iter budget via the new
|
||||
`max_iters` arg (clamped 1-32), and two built-in personas (`explore`,
|
||||
`verify`) are selectable inline via a `type` arg without writing a
|
||||
skill. Closes umbrella #316.
|
||||
|
||||
**Subagent:**
|
||||
|
||||
- feat(subagent): honor skill `allowed-tools` frontmatter when forking
|
||||
the child registry. The field was parsed but ignored ("Unused in v1");
|
||||
now it scopes the subagent to the named tools only. New
|
||||
`forkRegistryWithAllowList` helper alongside `forkRegistryExcluding`;
|
||||
`NEVER_INHERITED` (`spawn_subagent` / `submit_plan`) still wins so
|
||||
depth=1 + plan-mode guarantees hold even if a skill names them. An
|
||||
allow-list naming a tool the parent doesn't have returns a structured
|
||||
error result (no API call burned). (#317, PR #320)
|
||||
- feat(subagent): expose `max_iters` on the `spawn_subagent` tool
|
||||
schema. Clamped to 1-32 at the boundary; floats round down; non-numeric
|
||||
/ missing falls back to the registration-time default (still 16).
|
||||
Verify-style tasks can ask for 6-8, explore-style can ask for 24+.
|
||||
(#318, PR #321)
|
||||
- feat(subagent): two built-in personas selectable via `type` arg —
|
||||
`explore` (wide-net read-only investigation, 20-iter budget) and
|
||||
`verify` (narrow yes/no with evidence, 8-iter budget). Caller's
|
||||
explicit `system` / `max_iters` override the type's defaults. Prompts
|
||||
live in new `src/tools/subagent-types.ts` so `subagent.ts` stays
|
||||
under the 500-line target. (#319, PR #322)
|
||||
|
||||
## [0.27.3] — 2026-05-06
|
||||
|
||||
**Headline:** USD-account users now see `$` instead of `¥` everywhere
|
||||
money is shown in the TUI — wallet balance, turn cost, session cost,
|
||||
top-bar cost label, subagent end-event cost suffix, and the UsageCard
|
||||
header / body / wallet line. Pre-fix a USD wallet rendered
|
||||
`¥0.0352 turn · ¥0.461 session · wallet ¥0.91`; now it renders
|
||||
`$0.0308 turn · $0.064 session · wallet $0.91`. The display follows
|
||||
the wallet currency reported by the DeepSeek API (`currency: "USD"|"CNY"`),
|
||||
not the UI language — a CNY account on an English UI still sees `¥`,
|
||||
and vice versa. Originally reported in #278 by @Explosion-Scratch.
|
||||
|
||||
**UI / currency:**
|
||||
|
||||
- fix(ui): USD wallets render `$` for wallet balance, turn cost, and
|
||||
session cost. State + event schemas now carry `balanceCurrency`
|
||||
through `App.tsx → reducer → StatusBar` so every render site sees
|
||||
the wallet symbol the API reported. Originally drafted by @wviana
|
||||
in #272; the TUI plumbing through state.ts / cards.ts / events.ts /
|
||||
reducer.ts / useScrollback.ts / slash/types.ts was the bulk of the
|
||||
fix.
|
||||
- fix(ui): balance color threshold checks USD against the CNY scale
|
||||
(`$0.91 ≈ ¥6.55`) rather than treating `0.91` as `< ¥5 → red`. USD
|
||||
wallets now correctly show yellow at low-but-not-empty balances.
|
||||
- fix(ui): `StatsPanel.ChromeRow` cost label and `useSubagent`
|
||||
end-event cost suffix follow the wallet currency too — pre-fix
|
||||
these always rendered `$`. (#313)
|
||||
- refactor(ui): seven currency helpers in `theme/tokens.ts`
|
||||
(`formatCNY` / `formatBalance` / `formatBalanceLabel` /
|
||||
`formatWalletDisplay` / `formatCost` / `balanceColorCny` /
|
||||
`balanceColorForBalance`) collapsed to three: `formatBalance`,
|
||||
`formatCost`, `balanceColor`. Undefined currency defaults to CNY
|
||||
(matches pre-fix unconditional `¥`) so the transient first-turn
|
||||
case where balance arrived but currency hasn't is consistent.
|
||||
- chore(ui): remove orphan `ChromeBar.tsx` (258 lines). `App.tsx`
|
||||
mounts `StatsPanel`'s diverged `ChromeRow`, which is the bar users
|
||||
actually see. The two formatter helpers ChromeBar once owned now
|
||||
live in `theme/tokens.ts`. (#314)
|
||||
|
||||
**Loop:**
|
||||
|
||||
- refactor(loop): `loop.ts` 1331 → 1219 (−112). Three sibling files
|
||||
under `src/loop/`: `messages.ts` (pure ChatMessage builders),
|
||||
`turn-failure-tracker.ts` (per-turn failure count + threshold
|
||||
tipping), `force-summary.ts` (forced-summary generator behind a
|
||||
small DI context). Continues the #308 / #309 cadence — small
|
||||
per-helper extractions, no behavior change. (#311)
|
||||
|
||||
**Known follow-up:** `SessionPicker` still hardcodes `¥` for
|
||||
per-session cost in the session-history list, tracked in #312
|
||||
(good-first-issue).
|
||||
|
||||
## [0.25.1] — 2026-05-05
|
||||
|
||||
**Headline:** `run_command` learns the four common shell chain
|
||||
|
||||
@@ -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,23 @@ 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.
|
||||
|
||||
**Author your first skill:** Skills are markdown playbooks the model can invoke (`/skill <name>`). There's no remote registry yet — you author them directly:
|
||||
|
||||
```bash
|
||||
/skill new my-skill # scaffolds <project>/.reasonix/skills/my-skill.md
|
||||
/skill new my-skill --global # or under ~/.reasonix/skills for cross-project use
|
||||
```
|
||||
|
||||
Edit the file (`description:` frontmatter + body), then `/skill list` to see it. Add `runAs: subagent` to the frontmatter to spawn an isolated subagent loop instead of inlining the body.
|
||||
|
||||
<br/>
|
||||
|
||||
## What makes Reasonix different
|
||||
@@ -87,8 +150,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 +164,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 +180,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:
|
||||
|
||||
+20
-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,23 @@ 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` 始终显示当前锁定的工作区。
|
||||
|
||||
**写第一个 Skill:** Skills 是模型可以调用的 markdown 剧本(`/skill <name>`)。暂无在线市场 —— 自己写:
|
||||
|
||||
```bash
|
||||
/skill new my-skill # 在 <project>/.reasonix/skills/my-skill.md 生成模板
|
||||
/skill new my-skill --global # 或者放到 ~/.reasonix/skills,跨项目共用
|
||||
```
|
||||
|
||||
编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 里加 `runAs: subagent` 会以独立 subagent 跑,而不是把正文内联进父 prompt。
|
||||
|
||||
<br/>
|
||||
|
||||
## Reasonix 的不同之处
|
||||
@@ -99,7 +116,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 +132,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.
|
||||
@@ -45,7 +45,7 @@ const PROMPTS = [
|
||||
{ id: "m3", level: "medium", task: "A function \`debounceAsync<T extends any[], R>(fn: (...args: T) => Promise<R>, ms: number): (...args: T) => Promise<R>\` in src/util/debounce.ts. Resolves only the latest call's promise; earlier callers reject with an AbortError-like." },
|
||||
|
||||
// hard (2) — these touch domain types from the repo
|
||||
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`\${file}::\${fullName}\` with source='native'." },
|
||||
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`${file}::${fullName}\` with source='native'." },
|
||||
{ id: "h2", level: "hard", task: "A function \`pairRedGreen(events: Array<{type:string, test_id?:string, status?:string, ts:number}>): Array<{ test_id: string, red_ts: number, green_ts: number }>\` in src/events/pair.ts. For each test_id, find the most recent fail→pass transition and return one entry per test_id. Ignore test_ids that never went green." },
|
||||
];
|
||||
|
||||
|
||||
@@ -812,6 +812,9 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
}
|
||||
.chip-f:hover { background: var(--bg-hover); color: var(--fg-1); }
|
||||
.chip-f.active { color: var(--c-brand); border-color: var(--c-brand); background: rgba(121,192,255,.08); }
|
||||
.chip-f.static { cursor: default; }
|
||||
.chip-f.static:hover { background: var(--bg-elev); color: var(--fg-2); }
|
||||
.chip-f.static.active:hover { color: var(--c-brand); background: rgba(121,192,255,.08); }
|
||||
.chip-f .ct { color: var(--fg-4); font-size: 10px; }
|
||||
.chip-f.active .ct { color: var(--c-brand); }
|
||||
.chip-f .x { color: var(--fg-4); padding: 0 2px; }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -559,7 +559,7 @@ export function ChatPanel() {
|
||||
<div class="chat-shell">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">
|
||||
<div class="chips" style="padding:0">
|
||||
<span class="chip-f active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
|
||||
<span class="chip-f static active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
|
||||
</div>
|
||||
<div class="header-pickers" style="margin-left:auto">
|
||||
${
|
||||
@@ -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>
|
||||
|
||||
@@ -68,6 +68,7 @@ export function HooksPanel() {
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [eventFilter, setEventFilter] = useState<string>("all");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -129,19 +130,31 @@ export function HooksPanel() {
|
||||
data.events.length > 0
|
||||
? data.events
|
||||
: Array.from(new Set(matrixRows.flatMap((r) => Object.keys(r.cells))));
|
||||
const visibleRows =
|
||||
eventFilter === "all"
|
||||
? matrixRows
|
||||
: matrixRows.filter((r) => r.cells[eventFilter]?.on);
|
||||
const gridCols = `220px repeat(${Math.max(events.length, 1)}, minmax(0, 1fr))`;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
|
||||
${data.events.map((ev) => html`<span class="chip-f">${ev}</span>`)}
|
||||
<span
|
||||
class=${`chip-f ${eventFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setEventFilter("all")}
|
||||
>${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
|
||||
${data.events.map(
|
||||
(ev) => html`<span
|
||||
class=${`chip-f ${eventFilter === ev ? "active" : ""}`}
|
||||
onClick=${() => setEventFilter(ev)}
|
||||
>${ev}</span>`,
|
||||
)}
|
||||
</div>
|
||||
${info ? html`<div><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err">${error}</div>` : null}
|
||||
|
||||
${sectionH3(t("hooks.eventMatrix"), t("hooks.matrixSub", { scripts: matrixRows.length, s: matrixRows.length === 1 ? "" : "s", events: events.length }))}${
|
||||
matrixRows.length === 0
|
||||
visibleRows.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">
|
||||
${t("hooks.noHooks")}
|
||||
</div>`
|
||||
@@ -152,7 +165,7 @@ export function HooksPanel() {
|
||||
<div>${t("hooks.colScript")}</div>
|
||||
${events.map((ev) => html`<div>${ev}</div>`)}
|
||||
</div>
|
||||
${matrixRows.map(
|
||||
${visibleRows.map(
|
||||
(r) => html`
|
||||
<div class="row" style=${`grid-template-columns:${gridCols}`}>
|
||||
<div class="cell" title=${r.command}>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -122,8 +122,8 @@ export function PermissionsPanel() {
|
||||
}
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
|
||||
<span class="chip-f">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
|
||||
<span class="chip-f static active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
|
||||
<span class="chip-f static">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
@@ -37,6 +37,7 @@ export function PlansPanel() {
|
||||
const { data, error, loading } = usePoll<PlansData>("/plans", 8000);
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "done">("all");
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("plans.loading")}</div>`;
|
||||
@@ -48,13 +49,19 @@ export function PlansPanel() {
|
||||
${t("plans.noPlans")}
|
||||
</div>`;
|
||||
|
||||
const statusFiltered =
|
||||
statusFilter === "all"
|
||||
? plans
|
||||
: statusFilter === "active"
|
||||
? plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1)
|
||||
: plans.filter((p) => p.completionRatio >= 1);
|
||||
const filtered = filter.trim()
|
||||
? plans.filter(
|
||||
? statusFiltered.filter(
|
||||
(p) =>
|
||||
p.session.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(p.summary ?? "").toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: plans;
|
||||
: statusFiltered;
|
||||
|
||||
const open = openIdx !== null ? plans[openIdx] : null;
|
||||
|
||||
@@ -71,12 +78,21 @@ export function PlansPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${plans.length}</span></span>
|
||||
<span class="chip-f">
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("all")}
|
||||
>${t("common.all")} <span class="ct">${plans.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "active" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("active")}
|
||||
>
|
||||
${t("plans.active")}
|
||||
<span class="ct">${plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1).length}</span>
|
||||
</span>
|
||||
<span class="chip-f">
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "done" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("done")}
|
||||
>
|
||||
${t("plans.done")} <span class="ct">${plans.filter((p) => p.completionRatio >= 1).length}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -179,13 +179,13 @@ export function SemanticPanel() {
|
||||
<div style="display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:14px;align-items:start">
|
||||
<div style="display:flex;flex-direction:column;gap:10px;min-width:0">
|
||||
<div class="chips">
|
||||
<span class=${`chip-f ${idx?.exists ? "active" : ""}`}>
|
||||
<span class=${`chip-f static ${idx?.exists ? "active" : ""}`}>
|
||||
${idx?.exists ? t("semantic.indexBuilt") : t("semantic.noIndex")}
|
||||
</span>
|
||||
${
|
||||
ready
|
||||
? html`<span class="chip-f" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
|
||||
: html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
|
||||
? html`<span class="chip-f static" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
|
||||
: html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
|
||||
}
|
||||
</div>
|
||||
${info ? html`<div><span class="pill info">${info}</span></div>` : null}
|
||||
@@ -730,7 +730,7 @@ function ChipFormRow({
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||
${value.map(
|
||||
(e) => html`
|
||||
<span class="chip-f">
|
||||
<span class="chip-f static">
|
||||
<span>${e}</span>
|
||||
<span class="x" style="cursor:pointer" onClick=${() => remove(e)} title="remove">×</span>
|
||||
</span>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SessionsPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
|
||||
<span class="chip-f static active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
|
||||
</div>
|
||||
<div class="ssl-rows">
|
||||
${filtered.map(
|
||||
|
||||
@@ -29,6 +29,7 @@ export function SkillsPanel() {
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newScope, setNewScope] = useState<"global" | "project">("global");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [scopeFilter, setScopeFilter] = useState<"all" | Scope>("all");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -120,13 +121,15 @@ export function SkillsPanel() {
|
||||
...data.global.map((s) => ({ scope: "global" as Scope, ...s })),
|
||||
...data.builtin.map((s) => ({ scope: "builtin" as Scope, ...s })),
|
||||
];
|
||||
const scopeFiltered =
|
||||
scopeFilter === "all" ? allWith : allWith.filter((s) => s.scope === scopeFilter);
|
||||
const filtered = filter.trim()
|
||||
? allWith.filter(
|
||||
? scopeFiltered.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(s.description ?? "").toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: allWith;
|
||||
: scopeFiltered;
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
@@ -141,10 +144,22 @@ export function SkillsPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${allWith.length}</span></span>
|
||||
<span class="chip-f">${t("skills.project")} <span class="ct">${data.project.length}</span></span>
|
||||
<span class="chip-f">${t("skills.global")} <span class="ct">${data.global.length}</span></span>
|
||||
<span class="chip-f">${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("all")}
|
||||
>${t("common.all")} <span class="ct">${allWith.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "project" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("project")}
|
||||
>${t("skills.project")} <span class="ct">${data.project.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "global" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("global")}
|
||||
>${t("skills.global")} <span class="ct">${data.global.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "builtin" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("builtin")}
|
||||
>${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
|
||||
</div>
|
||||
|
||||
<div style="padding:0 12px 8px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
|
||||
@@ -37,8 +37,8 @@ export function ToolsPanel() {
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${d.total}</span></span>
|
||||
${d.planMode ? html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
|
||||
<span class="chip-f static active">${t("common.all")} <span class="ct">${d.total}</span></span>
|
||||
${d.planMode ? html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
@@ -174,8 +174,8 @@ export function UsagePanel() {
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
|
||||
<span class="chip-f">${u.logSize}</span>
|
||||
<span class="chip-f static active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
|
||||
<span class="chip-f static">${u.logSize}</span>
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
+24
-1
@@ -43,6 +43,29 @@ timestamps each turn — cache hit rate in practice: <20%.
|
||||
**Metric.** `prompt_cache_hit_tokens / (hit + miss)` exposed per-turn and
|
||||
aggregated per-session. Visible in the TUI's top-bar cache cell.
|
||||
|
||||
#### Parallel tool dispatch
|
||||
|
||||
Each tool declares `parallelSafe?: boolean` (default `false`). The loop
|
||||
dispatcher groups consecutive parallel-safe calls into chunks and races
|
||||
them via `Promise.allSettled`; the first non-parallel-safe call ends the
|
||||
chunk and runs alone (serial barrier — read-after-write order
|
||||
preserved). Tool-result yields and history append still land in declared
|
||||
order regardless of which call settles first, so the model sees the
|
||||
same shape it would under a fully serial dispatch.
|
||||
|
||||
| Env var | Default | Effect |
|
||||
|---|---|---|
|
||||
| `REASONIX_PARALLEL_MAX` | `3` (hard cap `16`) | Max chunk size. |
|
||||
| `REASONIX_TOOL_DISPATCH=serial` | unset | Forces serial dispatch — escape hatch. |
|
||||
|
||||
Built-in opt-ins: read-only filesystem (`read_file`, `list_directory`,
|
||||
`directory_tree`, `search_files`, `search_content`, `get_file_info`),
|
||||
web (`web_search`, `web_fetch`), `recall_memory`, `semantic_search`,
|
||||
isolated child loops (`run_skill`, `spawn_subagent`), in-memory job
|
||||
queries (`job_output`, `list_jobs`). Mutating / side-effecting tools
|
||||
stay default. MCP-bridged tools default `false` — third-party tools
|
||||
opt in only when the server explicitly declares parallel safety.
|
||||
|
||||
### Pillar 2 — R1 Thought Harvesting *(opt-in)*
|
||||
|
||||
**Problem.** R1 / V4-thinking emits extensive `reasoning_content`. DeepSeek's
|
||||
@@ -172,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
+562
-450
File diff suppressed because it is too large.
Load diff
+7
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.27.0",
|
||||
"version": "0.30.5",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -72,11 +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": {
|
||||
@@ -85,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",
|
||||
|
||||
+4
-2
@@ -315,8 +315,10 @@ export function expandAtMentions(
|
||||
for (const match of text.matchAll(AT_MENTION_PATTERN)) {
|
||||
const rawPath = match[1] ?? "";
|
||||
// Strip trailing dot (sentence terminator): `@foo.ts.` → `@foo.ts`.
|
||||
// Keep internal dots intact.
|
||||
const cleaned = rawPath.replace(/\.+$/, "");
|
||||
// Keep internal dots intact. Manual loop instead of `/\.+$/` — the
|
||||
// regex is O(n²) on dot-heavy non-matches per CodeQL js/polynomial-redos.
|
||||
let cleaned = rawPath;
|
||||
while (cleaned.endsWith(".")) cleaned = cleaned.slice(0, -1);
|
||||
if (!cleaned) continue;
|
||||
const token = `@${cleaned}`;
|
||||
if (seen.has(token)) continue;
|
||||
|
||||
+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,
|
||||
});
|
||||
}
|
||||
@@ -80,6 +80,14 @@ function detailsFor(e: Event): string {
|
||||
return `${e.callId} reason=${e.reason}`;
|
||||
case "tool.result":
|
||||
return `${e.callId} ${e.ok ? "ok" : "err"} ${e.output.length}B${e.truncated ? " [trunc]" : ""}`;
|
||||
case "tool.call":
|
||||
return `${e.name} args=${truncate(JSON.stringify(e.args), 60)}`;
|
||||
case "tool.confirm.allow":
|
||||
return `${e.kind} ${quote(e.payload.command, 60)}`;
|
||||
case "tool.confirm.deny":
|
||||
return `${e.kind} ${quote(e.payload.command, 60)}${e.denyContext ? ` — ${truncate(e.denyContext, 40)}` : ""}`;
|
||||
case "tool.confirm.always_allow":
|
||||
return `${e.kind} ${quote(e.payload.command, 60)} prefix=${truncate(e.prefix, 30)}`;
|
||||
case "effect.file.touched":
|
||||
return `${e.mode} ${e.path} (${e.bytes}B)`;
|
||||
case "effect.memory.written":
|
||||
|
||||
@@ -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 })
|
||||
@@ -35,6 +37,32 @@ export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMcpInspectFailure(err: unknown): string {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const message = error.message;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === "ENOENT") {
|
||||
const command = message.match(/^spawn\s+([^\s]+)\s+ENOENT$/)?.[1] ?? "the command";
|
||||
return `${message} — try: install or verify \`${command}\`, then check the MCP spec's command spelling`;
|
||||
}
|
||||
|
||||
if (code === "ECONNREFUSED") {
|
||||
const target = message.match(/\b(https?:\/\/\S+|\d+\.\d+\.\d+\.\d+:\d+|localhost:\d+)\b/i)?.[1];
|
||||
return `${message} — try: confirm ${target ?? "the MCP server"} is running and the host/port match the spec`;
|
||||
}
|
||||
|
||||
if (/^MCP request initialize \(id=\d+\) timed out after \d+ms$/.test(message)) {
|
||||
return `${message} — try: confirm the target speaks MCP and completes the handshake before the request timeout`;
|
||||
}
|
||||
|
||||
if (/^(empty MCP spec|MCP spec ".*" has name but no command)/.test(message)) {
|
||||
return `${message} — try: pass \`name=command args\` or an http(s):// URL`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function formatReport(nsName: string, r: InspectionReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`MCP server [${nsName}]`);
|
||||
|
||||
@@ -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 })
|
||||
|
||||
+6
-2
@@ -35,7 +35,7 @@ import { doctorCommand } from "./commands/doctor.js";
|
||||
import { eventsCommand } from "./commands/events.js";
|
||||
import { indexCommand } from "./commands/index.js";
|
||||
import { mcpBrowseCommand } from "./commands/mcp-browse.js";
|
||||
import { mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { formatMcpInspectFailure, mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { mcpInstallCommand, mcpListCommand, mcpSearchCommand } from "./commands/mcp.js";
|
||||
import { pruneSessionsCommand } from "./commands/prune-sessions.js";
|
||||
import { replayCommand } from "./commands/replay.js";
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,7 +418,7 @@ mcp
|
||||
try {
|
||||
await mcpInspectCommand({ spec, json: !!opts.json });
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp inspect failed: ${(err as Error).message}\n`);
|
||||
process.stderr.write(`mcp inspect failed: ${formatMcpInspectFailure(err)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
+255
-318
@@ -14,7 +14,7 @@ import {
|
||||
snapshotBeforeEdits,
|
||||
toWholeFileEditBlock,
|
||||
} from "../../code/edit-blocks.js";
|
||||
import { clearPendingEdits, loadPendingEdits, savePendingEdits } from "../../code/pending-edits.js";
|
||||
import { clearPendingEdits, loadPendingEdits } from "../../code/pending-edits.js";
|
||||
import {
|
||||
clearPlanState,
|
||||
loadPlanState,
|
||||
@@ -67,11 +67,12 @@ import { formatCommandResult, runCommand } from "../../tools/shell.js";
|
||||
import { registerSkillTools } from "../../tools/skills.js";
|
||||
import { formatSubagentResult, spawnSubagent } from "../../tools/subagent.js";
|
||||
import { webFetch } from "../../tools/web.js";
|
||||
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript/log.js";
|
||||
import { openTranscriptFile } from "../../transcript/log.js";
|
||||
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";
|
||||
@@ -84,7 +85,7 @@ import { SlashArgPicker } from "./SlashArgPicker.js";
|
||||
import { SlashSuggestions } from "./SlashSuggestions.js";
|
||||
import { WelcomeBanner } from "./WelcomeBanner.js";
|
||||
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
|
||||
import { formatEditResults, partitionEdits } from "./edit-history.js";
|
||||
import { formatEditResults } from "./edit-history.js";
|
||||
import { loopEventToDashboard } from "./effects/loop-to-dashboard.js";
|
||||
import { appendGlobalMemory, appendProjectMemory, detectHashMemory } from "./hash-memory.js";
|
||||
import { applySlashResult } from "./hooks/apply-slash-result.js";
|
||||
@@ -96,13 +97,20 @@ 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";
|
||||
import { useQuit } from "./hooks/useQuit.js";
|
||||
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,
|
||||
SubagentRow,
|
||||
SubagentLiveStack,
|
||||
ThinkingRow,
|
||||
UndoBanner,
|
||||
} from "./layout/LiveRows.js";
|
||||
@@ -121,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";
|
||||
@@ -292,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
|
||||
@@ -354,45 +370,16 @@ 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.
|
||||
const { activity: subagentActivity, sinkRef: subagentSinkRef } = useSubagent({
|
||||
// subagentRunner closure can read the ref. The wallet-currency thunk
|
||||
// reads from a ref populated AFTER useSessionInfo loads balance, so the
|
||||
// subagent-end cost suffix renders in the live wallet's symbol.
|
||||
const walletCurrencyRef = useRef<string | undefined>(undefined);
|
||||
const { activities: subagentActivities, sinkRef: subagentSinkRef } = useSubagent({
|
||||
session,
|
||||
log,
|
||||
getWalletCurrency: () => walletCurrencyRef.current,
|
||||
});
|
||||
// Transient "what's happening" text set by the loop during silent
|
||||
// phases (harvest round-trip, between-iteration R1 thinking, forced
|
||||
@@ -424,6 +411,7 @@ function AppInner({
|
||||
undoBanner,
|
||||
recordEdit,
|
||||
armUndoBanner,
|
||||
toggleUndoPause,
|
||||
codeUndo,
|
||||
codeHistory,
|
||||
codeShowEdit,
|
||||
@@ -538,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
|
||||
@@ -593,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
|
||||
@@ -613,13 +620,10 @@ function AppInner({
|
||||
// so we abort the in-flight turn and let the effect below fire the
|
||||
// submit once busy clears.
|
||||
const [queuedSubmit, setQueuedSubmit] = useState<string | null>(null);
|
||||
// Shell-style history of user prompts. ↑/↓ while idle walks it;
|
||||
// submit pushes to the end. Cursor -1 = "live input", 0+ = "N turns
|
||||
// back from newest". We don't persist history to disk — sessions
|
||||
// already keep the message log, and cross-session bash-style recall
|
||||
// would need per-project scoping we haven't designed.
|
||||
const promptHistory = useRef<string[]>([]);
|
||||
const historyCursor = useRef<number>(-1);
|
||||
// ↑/↓ recall over a turn-local prompt history. We don't persist to
|
||||
// disk — the session log already keeps the messages, and cross-
|
||||
// session bash-style recall would need per-project scoping.
|
||||
const { recallPrev, recallNext, pushHistory, resetCursor } = useInputRecall(setInput);
|
||||
// Disambiguates <Static> keys when a single turn yields multiple assistant_final events.
|
||||
const assistantIterCounter = useRef<number>(0);
|
||||
// Per-session @url fetch cache. Keyed by stripped URL; same URL
|
||||
@@ -628,32 +632,12 @@ function AppInner({
|
||||
// have changed. Shape mirrors AtUrlExpansion + an optional `body`
|
||||
// so the trailing block can be reconstructed from cache alone.
|
||||
const atUrlCache = useRef<Map<string, AtUrlExpansion & { body?: string }>>(new Map());
|
||||
// Active /loop state. Null when no loop is running. Re-issuing /loop
|
||||
// replaces the slot. Cancellation is centralized in stopLoop() so
|
||||
// every cancel-trigger (Esc, /clear, /new, user-typed submit, /loop
|
||||
// stop, exit) goes through one path. The timer is held in a sibling
|
||||
// ref so React effects don't have to re-run on every timer tick.
|
||||
const [activeLoop, setActiveLoop] = useState<{
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
} | null>(null);
|
||||
const loopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// handleSubmit is defined far below as a useCallback. The /loop timer
|
||||
// needs to call the LATEST closure on each firing (config could have
|
||||
// shifted mid-loop), so we mirror it through a ref. The mirror is
|
||||
// synced in a useEffect once handleSubmit is defined.
|
||||
const handleSubmitRef = useRef<((raw: string) => Promise<void>) | null>(null);
|
||||
const busyRef = useRef<boolean>(false);
|
||||
const activeLoopRef = useRef<typeof activeLoop>(activeLoop);
|
||||
// Set true by the loop timer just before it calls handleSubmitRef so
|
||||
// handleSubmit's "any user submit cancels the loop" guard knows to
|
||||
// skip itself. Reset to false at the top of every handleSubmit.
|
||||
const loopFiringRef = useRef<boolean>(false);
|
||||
useEffect(() => {
|
||||
activeLoopRef.current = activeLoop;
|
||||
}, [activeLoop]);
|
||||
// Full untruncated tool results, in arrival order. ToolCard clips
|
||||
// output at 400 chars for display; `/tool N` reads from this ref to
|
||||
// show the real thing. Not persisted — a
|
||||
@@ -794,6 +778,7 @@ function AppInner({
|
||||
// Per-skill model override (frontmatter `model: ...`),
|
||||
// else falls through to spawnSubagent's default.
|
||||
model: skill.model,
|
||||
allowedTools: skill.allowedTools,
|
||||
sink: subagentSinkRef.current,
|
||||
// Stamped onto every event so the TUI sink + usage log can
|
||||
// attribute the run to a skill without extra bookkeeping.
|
||||
@@ -827,6 +812,50 @@ function AppInner({
|
||||
return l;
|
||||
}, [model, system, harvest, branch, budgetUsd, session, tools, codeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !tools) return;
|
||||
tools.setAuditListener((event) => {
|
||||
const sink = eventSinkRef.current;
|
||||
const eventizer = eventizerRef.current;
|
||||
if (!sink || !eventizer) return;
|
||||
sink.append(eventizer.emitToolCall(loop.currentTurn, event.name, event.args));
|
||||
});
|
||||
pauseGate.setAuditListener((event) => {
|
||||
const sink = eventSinkRef.current;
|
||||
const eventizer = eventizerRef.current;
|
||||
if (!sink || !eventizer) return;
|
||||
switch (event.type) {
|
||||
case "tool.confirm.allow":
|
||||
sink.append(eventizer.emitToolConfirmAllow(loop.currentTurn, event.kind, event.payload));
|
||||
break;
|
||||
case "tool.confirm.deny":
|
||||
sink.append(
|
||||
eventizer.emitToolConfirmDeny(
|
||||
loop.currentTurn,
|
||||
event.kind,
|
||||
event.payload,
|
||||
event.denyContext,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "tool.confirm.always_allow":
|
||||
sink.append(
|
||||
eventizer.emitToolConfirmAlwaysAllow(
|
||||
loop.currentTurn,
|
||||
event.kind,
|
||||
event.payload,
|
||||
event.prefix,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
tools.setAuditListener(null);
|
||||
pauseGate.setAuditListener(null);
|
||||
};
|
||||
}, [loop, session, tools]);
|
||||
|
||||
// Keep the loop's hook list in sync after a `/hooks reload`. The
|
||||
// loop's field is intentionally mutable for exactly this case —
|
||||
// construction happens once, hook edits are picked up live.
|
||||
@@ -861,8 +890,12 @@ function AppInner({
|
||||
const balanceRef = useRef<typeof balance>(null);
|
||||
useEffect(() => {
|
||||
balanceRef.current = balance;
|
||||
walletCurrencyRef.current = balance?.currency;
|
||||
if (balance) {
|
||||
agentStore.dispatch({ type: "session.update", patch: { balance: balance.total } });
|
||||
agentStore.dispatch({
|
||||
type: "session.update",
|
||||
patch: { balance: balance.total, balanceCurrency: balance.currency },
|
||||
});
|
||||
}
|
||||
}, [balance, agentStore]);
|
||||
|
||||
@@ -1114,25 +1147,22 @@ function AppInner({
|
||||
}
|
||||
}, [session, loop, codeMode, syncPendingCount, log]);
|
||||
|
||||
// Ctrl+C exits, period. SIGINT (cooked-mode terminals + Node's
|
||||
// Windows console handler) and \x03 byte (raw-mode stdin) both
|
||||
// converge on `quitProcess`. We call `process.exit` directly rather
|
||||
// than Ink's `exit()` because the singleton stdin-reader keeps a
|
||||
// `data` listener attached — `exit()` would unmount the React tree
|
||||
// but the event loop would stay alive and the terminal would hang.
|
||||
// Esc handles "abort the current turn" separately; Ctrl+C is the
|
||||
// universal "I'm done" key, no banner, no double-press dance.
|
||||
const quitProcess = useCallback(() => {
|
||||
transcriptRef.current?.end();
|
||||
process.exit(0);
|
||||
}, []);
|
||||
// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
|
||||
const quitProcess = useQuit(transcriptRef);
|
||||
|
||||
useEffect(() => {
|
||||
process.on("SIGINT", quitProcess);
|
||||
return () => {
|
||||
process.off("SIGINT", quitProcess);
|
||||
};
|
||||
}, [quitProcess]);
|
||||
// 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
|
||||
@@ -1176,14 +1206,14 @@ function AppInner({
|
||||
// Esc during a busy turn also kills any active /loop — the user
|
||||
// is taking over. Loops persist past plain Esc when the system is
|
||||
// idle so a long-cadence loop doesn't die from random key noise.
|
||||
if (activeLoopRef.current) stopLoop();
|
||||
if (isLoopActive()) stopLoop();
|
||||
loop.abort();
|
||||
return;
|
||||
}
|
||||
// Esc when idle ALSO cancels an active loop, since hitting Esc with
|
||||
// nothing else going on is a clear "stop whatever's running"
|
||||
// gesture. No-op when no loop is active.
|
||||
if (key.escape && !busy && activeLoopRef.current) {
|
||||
if (key.escape && !busy && isLoopActive()) {
|
||||
stopLoop();
|
||||
return;
|
||||
}
|
||||
@@ -1236,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;
|
||||
@@ -1269,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
|
||||
@@ -1349,26 +1423,6 @@ function AppInner({
|
||||
// buffer.
|
||||
});
|
||||
|
||||
// History recall callbacks passed into PromptInput. Walking the
|
||||
// in-memory `promptHistory` list: ↑ moves backward through prior
|
||||
// prompts (increments historyCursor); ↓ moves forward until we
|
||||
// fall off the end, then resets to empty. No-op when the history
|
||||
// is empty or we're already at the boundary.
|
||||
const recallPrev = useCallback(() => {
|
||||
const hist = promptHistory.current;
|
||||
if (hist.length === 0) return;
|
||||
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(hist[hist.length - 1 - nextCursor] ?? "");
|
||||
}, []);
|
||||
const recallNext = useCallback(() => {
|
||||
if (historyCursor.current < 0) return;
|
||||
const hist = promptHistory.current;
|
||||
const nextCursor = historyCursor.current - 1;
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
|
||||
}, []);
|
||||
|
||||
// Edit-gate interceptor. Reroutes `edit_file` / `write_file` tool
|
||||
// calls through the review queue (in `review` mode) or the auto-apply
|
||||
// snapshot/banner path (in `auto` mode) so the model's tool usage
|
||||
@@ -1479,83 +1533,18 @@ function AppInner({
|
||||
};
|
||||
}, [tools, codeMode, session, recordEdit, armUndoBanner, syncPendingCount, setEditMode]);
|
||||
|
||||
/**
|
||||
* /apply callback — write pending edit blocks to disk, snapshot
|
||||
* beforehand so /undo still works, report per-file results. With
|
||||
* `indices` (1-based) only those blocks are applied; the rest stay
|
||||
* pending so the user can iterate on them. Empty / undefined indices
|
||||
* apply every pending block (the all-or-nothing original behavior).
|
||||
*/
|
||||
const codeApply = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) {
|
||||
return "nothing pending — the model hasn't proposed edits since the last /apply or /discard.";
|
||||
}
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing applied. Use /apply with no args to commit them all.";
|
||||
}
|
||||
const snaps = snapshotBeforeEdits(selected, currentRootDir);
|
||||
const results = applyEditBlocks(selected, currentRootDir);
|
||||
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
if (anyApplied) recordEdit("review-apply", selected, results, snaps);
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? `\n▸ ${remaining.length} edit block(s) still pending — /apply or /discard to clear them.`
|
||||
: "";
|
||||
return formatEditResults(results) + tail;
|
||||
},
|
||||
[codeMode, currentRootDir, session, syncPendingCount, recordEdit],
|
||||
);
|
||||
|
||||
/**
|
||||
* /discard callback — forget the pending edits without touching
|
||||
* disk. With `indices` (1-based) only those blocks are dropped; the
|
||||
* rest stay pending. Empty / undefined indices drop everything.
|
||||
*/
|
||||
const codeDiscard = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) return "nothing pending to discard.";
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing discarded.";
|
||||
}
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? ` (${remaining.length} block(s) still pending)`
|
||||
: ". Nothing was written to disk.";
|
||||
return `▸ discarded ${selected.length} pending edit block(s)${tail}`;
|
||||
},
|
||||
[session, syncPendingCount],
|
||||
);
|
||||
const { codeApply, codeDiscard } = useCodeMode({
|
||||
codeMode: !!codeMode,
|
||||
pendingEdits,
|
||||
currentRootDir,
|
||||
session: session ?? null,
|
||||
syncPendingCount,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
const prefixHash = loop.prefix.fingerprint;
|
||||
|
||||
const writeTranscript = useCallback(
|
||||
(ev: LoopEvent) => {
|
||||
const stream = transcriptRef.current;
|
||||
if (!stream) return;
|
||||
writeRecord(stream, recordFromLoopEvent(ev, { model, prefixHash }));
|
||||
},
|
||||
[model, prefixHash],
|
||||
);
|
||||
const writeTranscript = useTranscriptWriter(transcriptRef, model, prefixHash);
|
||||
|
||||
/**
|
||||
* Toggle plan mode on the local state AND on the ToolRegistry. The
|
||||
@@ -1576,39 +1565,15 @@ function AppInner({
|
||||
setPendingPlan(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cancel the active /loop. Centralized so every cancel-trigger
|
||||
* (explicit /loop stop, Esc, /clear, /new, exit, the very first
|
||||
* user-typed prompt while a loop is active) goes through one path.
|
||||
* Idempotent — calling with no active loop is a no-op.
|
||||
*/
|
||||
const stopLoop = useCallback(() => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
setActiveLoop(null);
|
||||
log.pushInfo(`▸ loop stopped (after ${cur.iter} iter${cur.iter === 1 ? "" : "s"}).`);
|
||||
}, [log]);
|
||||
|
||||
/**
|
||||
* Start a new /loop. Replaces any prior loop. The actual timer is
|
||||
* scheduled by the useEffect downstream that watches `activeLoop`.
|
||||
*/
|
||||
const startLoop = useCallback((intervalMs: number, prompt: string) => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
setActiveLoop({
|
||||
prompt,
|
||||
intervalMs,
|
||||
nextFireAt: Date.now() + intervalMs,
|
||||
iter: 0,
|
||||
});
|
||||
}, []);
|
||||
const {
|
||||
startLoop,
|
||||
stopLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
activeLoop,
|
||||
} = useLoopMode({ log, busyRef, handleSubmitRef });
|
||||
|
||||
/**
|
||||
* Mount the per-block walkthrough modal against the pending-edits
|
||||
@@ -1966,30 +1931,17 @@ function AppInner({
|
||||
[codeApply, codeDiscard, log],
|
||||
);
|
||||
|
||||
/** Snapshot for the `/loop` (no-arg) status branch. */
|
||||
const getLoopStatus = useCallback(() => {
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return null;
|
||||
return {
|
||||
prompt: cur.prompt,
|
||||
intervalMs: cur.intervalMs,
|
||||
iter: cur.iter,
|
||||
nextFireMs: Math.max(0, cur.nextFireAt - Date.now()),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (raw: string) => {
|
||||
let text = raw.trim();
|
||||
if (!text) return;
|
||||
// Cancel-on-user-input: any user-typed submit cancels an active
|
||||
// /loop, regardless of busy state. Loop-fired submits set the
|
||||
// `loopFiringRef` flag so the timer's own re-submit doesn't
|
||||
// self-cancel.
|
||||
if (activeLoopRef.current && !loopFiringRef.current) {
|
||||
// firing flag so the timer's own re-submit doesn't self-cancel.
|
||||
if (isLoopActive() && !isLoopFiring()) {
|
||||
stopLoop();
|
||||
}
|
||||
loopFiringRef.current = false;
|
||||
clearFiringFlag();
|
||||
if (busy) return;
|
||||
|
||||
// @-mention picker intercept. When the picker is open (trailing
|
||||
@@ -2037,7 +1989,7 @@ function AppInner({
|
||||
}
|
||||
|
||||
setInput("");
|
||||
historyCursor.current = -1;
|
||||
resetCursor();
|
||||
|
||||
// Y/N fast-path when edits are pending. One keystroke is all it
|
||||
// takes to commit or drop — matches the muscle memory of `git
|
||||
@@ -2046,7 +1998,7 @@ function AppInner({
|
||||
// when nothing's waiting.
|
||||
if (codeMode && pendingEdits.current.length > 0 && (text === "y" || text === "n")) {
|
||||
log.pushInfo(text === "y" ? codeApply() : codeDiscard());
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2059,7 +2011,7 @@ function AppInner({
|
||||
if (hashParse?.kind === "memory" || hashParse?.kind === "memory-global") {
|
||||
const isGlobal = hashParse.kind === "memory-global";
|
||||
const memRoot = currentRootDir;
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
try {
|
||||
const result = isGlobal
|
||||
? appendGlobalMemory(hashParse.note)
|
||||
@@ -2088,7 +2040,7 @@ function AppInner({
|
||||
const bangCmd = detectBangCommand(text);
|
||||
if (bangCmd !== null) {
|
||||
const bangRoot = currentRootDir;
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
log.pushUser(text);
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -2120,7 +2072,7 @@ function AppInner({
|
||||
if (mcpBrowseMatch) {
|
||||
const kind = mcpBrowseMatch[1] as "resource" | "prompt";
|
||||
const arg = mcpBrowseMatch[2]?.trim() ?? "";
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
log.pushUser(text);
|
||||
await handleMcpBrowseSlash(kind, arg, liveMcpServers, log);
|
||||
return;
|
||||
@@ -2196,16 +2148,21 @@ function AppInner({
|
||||
if (result.openSessionsPicker) {
|
||||
setSessionsPickerList(listSessionsForWorkspace(currentRootDir));
|
||||
setPendingSessionsPicker(true);
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openMcpHub) {
|
||||
setPendingMcpHub({ tab: result.openMcpHub.tab });
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openModelPicker) {
|
||||
setPendingModelPicker(true);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openArgPickerFor) {
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
setInput(`/${result.openArgPickerFor} `);
|
||||
return;
|
||||
}
|
||||
@@ -2216,10 +2173,10 @@ function AppInner({
|
||||
syncPendingCount,
|
||||
session: session ?? null,
|
||||
codeModeOn: !!codeMode,
|
||||
activeLoopRef,
|
||||
isLoopActive,
|
||||
stopLoop,
|
||||
quitProcess,
|
||||
promptHistory,
|
||||
pushHistory,
|
||||
text,
|
||||
});
|
||||
if (outcome.kind === "resubmit") {
|
||||
@@ -2250,7 +2207,7 @@ function AppInner({
|
||||
// Large pastes (stack traces, log dumps, file contents) get a
|
||||
// collapsed preview in scrollback; the model still receives the full
|
||||
// text below via modelInput.
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
const pasteDisplay = formatLongPaste(text);
|
||||
const userId = log.pushUser(pasteDisplay.displayText);
|
||||
broadcastDashboardEvent({ kind: "user", id: userId, text });
|
||||
@@ -2463,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, {
|
||||
@@ -2591,6 +2559,9 @@ function AppInner({
|
||||
stopLoop,
|
||||
startLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
startWalkthrough,
|
||||
startDashboard,
|
||||
stopDashboard,
|
||||
@@ -2602,6 +2573,8 @@ function AppInner({
|
||||
log,
|
||||
agentStore.dispatch,
|
||||
mcpRuntime,
|
||||
pushHistory,
|
||||
resetCursor,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2612,49 +2585,6 @@ function AppInner({
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
||||
// /loop timer. Re-runs whenever activeLoop's `nextFireAt` shifts —
|
||||
// either because startLoop set a fresh schedule or because the
|
||||
// previous timer fired and bumped the next-fire time. Cleanup
|
||||
// clears the in-flight timer so a stopLoop / replacement doesn't
|
||||
// leak a fire after cancel.
|
||||
useEffect(() => {
|
||||
if (!activeLoop) return;
|
||||
const delay = Math.max(0, activeLoop.nextFireAt - Date.now());
|
||||
const timer = setTimeout(async () => {
|
||||
loopTimerRef.current = null;
|
||||
// Skip the firing entirely when a prior turn is still running.
|
||||
// Re-arm in 1s so the loop catches up the moment busy clears,
|
||||
// rather than waiting a full interval after a slow turn.
|
||||
if (busyRef.current) {
|
||||
setActiveLoop((cur) => (cur ? { ...cur, nextFireAt: Date.now() + 1000 } : cur));
|
||||
return;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
const nextIter = cur.iter + 1;
|
||||
// Schedule the NEXT firing now (independent of how long this
|
||||
// turn takes). Keeps the cadence honest even when individual
|
||||
// turns are slow.
|
||||
setActiveLoop((c) =>
|
||||
c ? { ...c, iter: nextIter, nextFireAt: Date.now() + cur.intervalMs } : c,
|
||||
);
|
||||
log.pushInfo(`▸ /loop iter ${nextIter} → ${cur.prompt}`);
|
||||
loopFiringRef.current = true;
|
||||
try {
|
||||
await handleSubmitRef.current?.(cur.prompt);
|
||||
} catch {
|
||||
// Persistent submission errors → kill the loop rather than
|
||||
// spam the screen. User can re-issue /loop once they fix
|
||||
// whatever's wrong.
|
||||
stopLoop();
|
||||
} finally {
|
||||
loopFiringRef.current = false;
|
||||
}
|
||||
}, delay);
|
||||
loopTimerRef.current = timer;
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeLoop, stopLoop, log]);
|
||||
|
||||
/**
|
||||
* ShellConfirm callback. Resolves the PauseGate so the
|
||||
* blocked tool function can proceed. The tool handles running the
|
||||
@@ -3145,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
|
||||
@@ -3188,6 +3103,7 @@ function AppInner({
|
||||
{!hasConversation && !busy && !isStreaming ? (
|
||||
<WelcomeBanner
|
||||
inCodeMode={!!codeMode}
|
||||
workspaceRoot={codeMode ? currentRootDir : undefined}
|
||||
dashboardUrl={dashboardUrl}
|
||||
languageVersion={languageVersion}
|
||||
/>
|
||||
@@ -3218,8 +3134,8 @@ function AppInner({
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
subagentActivity ? (
|
||||
<SubagentRow activity={subagentActivity} />
|
||||
subagentActivities.length > 0 ? (
|
||||
<SubagentLiveStack activities={subagentActivities} max={3} />
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
@@ -3332,6 +3248,7 @@ function AppInner({
|
||||
<SessionPicker
|
||||
sessions={sessionsPickerList}
|
||||
workspace={currentRootDir}
|
||||
walletCurrency={walletCurrencyRef.current}
|
||||
onChoose={(outcome) => {
|
||||
if (outcome.kind === "open") {
|
||||
setPendingSessionsPicker(false);
|
||||
@@ -3370,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}
|
||||
@@ -3446,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 ? (
|
||||
@@ -3467,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,259 +0,0 @@
|
||||
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 stringWidth from "string-width";
|
||||
import type { SessionSummary } from "../../telemetry/stats.js";
|
||||
import { ChromeRule } from "./primitives.js";
|
||||
import { COLOR, GRADIENT } from "./theme.js";
|
||||
|
||||
const COLD_START_TURNS = 3;
|
||||
const CACHE_BAR_CELLS = 10;
|
||||
|
||||
export type Preset = "auto" | "flash" | "pro";
|
||||
|
||||
export interface ChromeBarProps {
|
||||
summary: SessionSummary;
|
||||
rootDir?: string;
|
||||
sessionName?: string | null;
|
||||
preset?: Preset;
|
||||
planMode?: boolean;
|
||||
proArmed?: boolean;
|
||||
escalated?: boolean;
|
||||
updateAvailable?: string | null;
|
||||
balance?: { currency: string; total: number } | null;
|
||||
budgetUsd?: number | null;
|
||||
/** Scroll progress: 0 = at latest, 1 = at oldest. Hides the pill at 0. */
|
||||
scrollRatio?: number;
|
||||
}
|
||||
|
||||
export function ChromeBar(props: ChromeBarProps): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = (stdout?.columns ?? 80) - 2; // subtract paddingX={1} on both sides
|
||||
const cold = props.summary.turns <= COLD_START_TURNS;
|
||||
const projectName = props.rootDir ? basename(props.rootDir) : null;
|
||||
const mode = pickModePill(props.planMode, props.preset);
|
||||
const proPill = props.escalated
|
||||
? { label: "⇧ pro", color: COLOR.err }
|
||||
: props.proArmed
|
||||
? { label: "⇧ pro", color: COLOR.warn }
|
||||
: null;
|
||||
|
||||
// Greedy width-aware fit. Layout (each gap is exactly ONE space — single
|
||||
// suffix on update/mode/pro/scroll, single prefix on balance/cache):
|
||||
// [brand][·project][›session]<spacer>[scroll][update][mode][pro]$cost w bal cache █▓▓▓
|
||||
// Always-shown carve out the budget first; optional pieces are dropped
|
||||
// by priority — balance > cache > session > update — until the row fits.
|
||||
// (`flexGrow` spacer can shrink to 0, so no minimum reserve.) Cache is
|
||||
// shown unconditionally now: pre-turn-4 it renders dim with a "—" so
|
||||
// it's "default on" instead of materializing only after a few turns.
|
||||
const SEP = 3; // " · " / " › "
|
||||
const GAP = 1;
|
||||
const scrollLabel =
|
||||
props.scrollRatio !== undefined && props.scrollRatio > 0
|
||||
? `[↑ ${Math.round(props.scrollRatio * 100)}%]`
|
||||
: "";
|
||||
const updateLabel = props.updateAvailable ? `↑ ${props.updateAvailable}` : "";
|
||||
const balanceLabel = props.balance ? formatBalanceLabel(props.balance) : "";
|
||||
const cachePct = Math.round(props.summary.cacheHitRatio * 100);
|
||||
// Use the worst-case rendered cache string (3-digit pct, full bar) so the
|
||||
// shed decision doesn't oscillate as the percentage changes turn-to-turn.
|
||||
const cacheLabel = `cache ${"█".repeat(CACHE_BAR_CELLS)} 100%`;
|
||||
const costLabel = `$${props.summary.totalCostUsd.toFixed(4)}${
|
||||
props.budgetUsd && props.budgetUsd > 0 ? ` / $${props.budgetUsd.toFixed(2)}` : ""
|
||||
}`;
|
||||
|
||||
const brandW = stringWidth("◈ reasonix");
|
||||
const projectW = projectName ? SEP + stringWidth(projectName) : 0;
|
||||
const fixedLeft = brandW + projectW;
|
||||
const scrollW = scrollLabel ? stringWidth(scrollLabel) + GAP : 0;
|
||||
const modeW = mode ? stringWidth(`[${mode.label}]`) + GAP : 0;
|
||||
const proW = proPill ? stringWidth(`[${proPill.label}]`) + GAP : 0;
|
||||
const costW = stringWidth(costLabel);
|
||||
const fixedRight = scrollW + modeW + proW + costW;
|
||||
let budget = cols - fixedLeft - fixedRight;
|
||||
|
||||
const balanceOptW = balanceLabel ? GAP + stringWidth(balanceLabel) : 0;
|
||||
const cacheOptW = GAP + stringWidth(cacheLabel);
|
||||
const sessionOptW = props.sessionName ? SEP + stringWidth(props.sessionName) : 0;
|
||||
const updateOptW = updateLabel ? stringWidth(updateLabel) + GAP : 0;
|
||||
|
||||
const showBalance = balanceOptW > 0 && budget >= balanceOptW;
|
||||
if (showBalance) budget -= balanceOptW;
|
||||
const showCache = budget >= cacheOptW;
|
||||
if (showCache) budget -= cacheOptW;
|
||||
const showSession = sessionOptW > 0 && budget >= sessionOptW;
|
||||
if (showSession) budget -= sessionOptW;
|
||||
const showUpdate = updateOptW > 0 && budget >= updateOptW;
|
||||
if (showUpdate) budget -= updateOptW;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box>
|
||||
<Text bold color={GRADIENT[0]}>
|
||||
{"◈ "}
|
||||
</Text>
|
||||
<Text color={COLOR.brand} bold>
|
||||
reasonix
|
||||
</Text>
|
||||
{projectName ? (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{" · "}
|
||||
</Text>
|
||||
<Text>{projectName}</Text>
|
||||
{showSession && props.sessionName ? (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{" › "}
|
||||
</Text>
|
||||
<Text color={COLOR.info}>{props.sessionName}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{scrollLabel ? (
|
||||
<>
|
||||
<Text color={COLOR.accent} bold>
|
||||
{scrollLabel}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{showUpdate ? (
|
||||
<>
|
||||
<Text color={COLOR.warn} bold>
|
||||
{updateLabel}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{mode ? (
|
||||
<>
|
||||
<Text color={mode.color} bold>
|
||||
{`[${mode.label}]`}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{proPill ? (
|
||||
<>
|
||||
<Text color={proPill.color} bold>
|
||||
{`[${proPill.label}]`}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
<CostPill summary={props.summary} cold={cold} budgetUsd={props.budgetUsd} />
|
||||
{showBalance && props.balance ? (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<BalancePill balance={props.balance} />
|
||||
</>
|
||||
) : null}
|
||||
{showCache ? (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<CachePill ratio={props.summary.cacheHitRatio} cold={cold} pct={cachePct} />
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<ChromeRule />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBalanceLabel(balance: { currency: string; total: number }): string {
|
||||
const sym = balance.currency === "USD" ? "$" : balance.currency === "CNY" ? "¥" : "";
|
||||
const suf = sym ? "" : ` ${balance.currency}`;
|
||||
return `w ${sym}${balance.total.toFixed(2)}${suf}`;
|
||||
}
|
||||
|
||||
function CostPill({
|
||||
summary,
|
||||
cold,
|
||||
budgetUsd,
|
||||
}: {
|
||||
summary: SessionSummary;
|
||||
cold: boolean;
|
||||
budgetUsd?: number | null;
|
||||
}): React.ReactElement {
|
||||
const cost = summary.totalCostUsd;
|
||||
const color = cold ? COLOR.info : sessionCostColor(cost);
|
||||
return (
|
||||
<>
|
||||
<Text color={color} bold={!cold} dimColor={cold}>
|
||||
{`$${cost.toFixed(4)}`}
|
||||
</Text>
|
||||
{budgetUsd && budgetUsd > 0 ? (
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{` / $${budgetUsd.toFixed(2)}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BalancePill({
|
||||
balance,
|
||||
}: {
|
||||
balance: { currency: string; total: number };
|
||||
}): React.ReactElement {
|
||||
const { total } = balance;
|
||||
const color = total < 1 ? COLOR.err : total < 5 ? COLOR.warn : COLOR.ok;
|
||||
return <Text color={color}>{formatBalanceLabel(balance)}</Text>;
|
||||
}
|
||||
|
||||
function CachePill({
|
||||
ratio,
|
||||
cold,
|
||||
pct,
|
||||
}: {
|
||||
ratio: number;
|
||||
cold: boolean;
|
||||
pct: number;
|
||||
}): React.ReactElement {
|
||||
const color = ratio >= 0.7 ? COLOR.ok : ratio >= 0.4 ? COLOR.warn : COLOR.err;
|
||||
const filled = Math.max(0, Math.min(CACHE_BAR_CELLS, Math.round(ratio * CACHE_BAR_CELLS)));
|
||||
const empty = CACHE_BAR_CELLS - filled;
|
||||
return (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{"cache "}
|
||||
</Text>
|
||||
<Text color={cold ? COLOR.info : color} dimColor={cold}>
|
||||
{"█".repeat(filled)}
|
||||
</Text>
|
||||
<Text dimColor>{"░".repeat(empty)}</Text>
|
||||
<Text color={cold ? undefined : color} bold={!cold} dimColor={cold}>
|
||||
{cold && pct === 0 ? " —" : ` ${pct}%`}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function pickModePill(
|
||||
planMode: boolean | undefined,
|
||||
preset: Preset | undefined,
|
||||
): { label: string; color: string } | null {
|
||||
if (planMode) return { label: "PLAN", color: COLOR.err };
|
||||
if (!preset) return null;
|
||||
if (preset === "pro") return { label: "pro", color: COLOR.accent };
|
||||
if (preset === "flash") return { label: "flash", color: COLOR.info };
|
||||
return { label: "auto", color: COLOR.primary };
|
||||
}
|
||||
|
||||
function sessionCostColor(cost: number): string | undefined {
|
||||
if (cost <= 0) return undefined;
|
||||
if (cost >= 5) return COLOR.err;
|
||||
if (cost >= 0.5) return COLOR.warn;
|
||||
return COLOR.ok;
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
const norm = path.replace(/\\/g, "/");
|
||||
const i = norm.lastIndexOf("/");
|
||||
return i === -1 ? norm : norm.slice(i + 1);
|
||||
}
|
||||
@@ -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,14 +1,14 @@
|
||||
/** 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";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
import { CARD, TONE } from "./theme/tokens.js";
|
||||
import { MarkdownView } from "./markdown-view.js";
|
||||
import { CARD, FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type PlanConfirmChoice = "approve" | "refine" | "revise" | "cancel";
|
||||
|
||||
@@ -21,9 +21,19 @@ export interface PlanConfirmProps {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
const PLAN_BODY_PREVIEW_LINES = 24;
|
||||
|
||||
function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
|
||||
const stepRows = steps?.length ?? 0;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(16, stepRows + 14) });
|
||||
const hasSteps = stepRows > 0;
|
||||
const planLines = plan.split("\n");
|
||||
const truncatedBody = planLines.length > PLAN_BODY_PREVIEW_LINES;
|
||||
const previewBody = truncatedBody ? planLines.slice(0, PLAN_BODY_PREVIEW_LINES).join("\n") : plan;
|
||||
const previewRows = truncatedBody
|
||||
? PLAN_BODY_PREVIEW_LINES
|
||||
: Math.min(planLines.length, PLAN_BODY_PREVIEW_LINES);
|
||||
const reservedFor = hasSteps ? stepRows : previewRows;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + 14) });
|
||||
|
||||
const hasOpenQuestions =
|
||||
/^#{1,6}\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear)/im.test(plan) ||
|
||||
@@ -45,9 +55,20 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{steps && steps.length > 0 ? (
|
||||
{hasSteps ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<PlanStepList steps={steps} />
|
||||
<PlanStepList steps={steps!} />
|
||||
</Box>
|
||||
) : plan.trim().length > 0 ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<MarkdownView text={previewBody} />
|
||||
{truncatedBody ? (
|
||||
<Text color={FG.faint}>
|
||||
{`… ${planLines.length - PLAN_BODY_PREVIEW_LINES} more line${
|
||||
planLines.length - PLAN_BODY_PREVIEW_LINES === 1 ? "" : "s"
|
||||
} above in scrollback`}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<SingleSelect
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { EditMode } from "../../config.js";
|
||||
import type { SessionSummary } from "../../telemetry/stats.js";
|
||||
import { Bar, ChromeRule } from "./primitives.js";
|
||||
import { COLOR, GRADIENT } from "./theme.js";
|
||||
import { formatBalance, formatCost } from "./theme/tokens.js";
|
||||
|
||||
const COLD_START_TURNS = 3;
|
||||
|
||||
@@ -91,9 +92,9 @@ function ChromeRow({
|
||||
const cacheColor =
|
||||
summary.cacheHitRatio >= 0.7 ? COLOR.ok : summary.cacheHitRatio >= 0.4 ? COLOR.warn : COLOR.err;
|
||||
const balanceLabel = balance
|
||||
? `[w ${balance.currency === "USD" ? "$" : ""}${balance.total.toFixed(2)}${balance.currency !== "USD" ? ` ${balance.currency}` : ""}]`
|
||||
? `[${formatBalance(balance.total, balance.currency, { label: true })}]`
|
||||
: "";
|
||||
const costLabel = `[$${summary.totalCostUsd.toFixed(4)}]`;
|
||||
const costLabel = `[${formatCost(summary.totalCostUsd, balance?.currency)}]`;
|
||||
const cacheLabel = "[c ▰▰▰▰▰▰ 100%]";
|
||||
const updateLabel = updateAvailable ? `↑ ${updateAvailable}` : "";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+67
-1
@@ -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();
|
||||
@@ -443,5 +506,8 @@ export function buildSpec(name: string, argsByName: Record<string, string>): str
|
||||
}
|
||||
|
||||
function quoteIfNeeded(s: string): string {
|
||||
return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
||||
// Escape backslashes BEFORE quotes — otherwise a trailing `\` in the
|
||||
// input would consume the closing quote when a downstream parser
|
||||
// un-escapes the output (CodeQL js/incomplete-sanitization).
|
||||
return /\s|"/.test(s) ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { UsageCard as UsageCardData } from "../state/cards.js";
|
||||
import { FG, TONE, formatCNY } from "../theme/tokens.js";
|
||||
import { FG, TONE, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const BAR_CELLS = 30;
|
||||
|
||||
@@ -32,7 +32,7 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
|
||||
const reasonRatio = card.tokens.reason / cap;
|
||||
const outputRatio = card.tokens.output / cap;
|
||||
|
||||
const headerMeta: string[] = [`turn ${card.turn}`, formatCNY(card.cost)];
|
||||
const headerMeta: string[] = [`turn ${card.turn}`, formatCost(card.cost, card.balanceCurrency)];
|
||||
if (card.elapsedMs !== undefined) headerMeta.push(`${(card.elapsedMs / 1000).toFixed(1)}s`);
|
||||
return (
|
||||
<Card tone={FG.meta}>
|
||||
@@ -66,11 +66,15 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.faint}>session</Text>
|
||||
<Text bold color={FG.body}>{`⛁ ${formatCNY(card.sessionCost, 3)}`}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{`⛁ ${formatCost(card.sessionCost, card.balanceCurrency, 3)}`}
|
||||
</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<>
|
||||
<Text color={FG.faint}>· balance</Text>
|
||||
<Text bold color={TONE.brand}>{`¥${card.balance.toFixed(2)}`}</Text>
|
||||
<Text bold color={TONE.brand}>
|
||||
{formatBalance(card.balance, card.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -89,9 +93,9 @@ function CompactUsageRow({ card }: { card: UsageCardData }): React.ReactElement
|
||||
</Text>
|
||||
<Text color={FG.faint}>· cache</Text>
|
||||
<Text color={TONE.ok}>{`${(card.cacheHit * 100).toFixed(0)}%`}</Text>
|
||||
<Text color={FG.faint}>{`· ${formatCNY(card.cost)}${elapsed}`}</Text>
|
||||
<Text color={FG.faint}>{`· ${formatCost(card.cost, card.balanceCurrency)}${elapsed}`}</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<Text color={TONE.brand}>{`· ¥${card.balance.toFixed(2)}`}</Text>
|
||||
<Text color={TONE.brand}>{`· ${formatBalance(card.balance, card.balanceCurrency)}`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** OSC 52 clipboard write + temp-file fallback. */
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -13,7 +13,11 @@ export interface ClipboardWrite {
|
||||
}
|
||||
|
||||
export function writeClipboard(text: string): ClipboardWrite {
|
||||
const filePath = join(tmpdir(), `reasonix-clip-${Date.now()}.txt`);
|
||||
// mkdtemp creates a private 0700 directory atomically — keeps the
|
||||
// file out of the shared tmp namespace where a sibling process can
|
||||
// race or read it (CodeQL js/insecure-temporary-file).
|
||||
const dir = mkdtempSync(join(tmpdir(), "reasonix-clip-"));
|
||||
const filePath = join(dir, "clip.txt");
|
||||
let osc52 = false;
|
||||
if (text.length <= OSC_52_LIMIT) {
|
||||
const b64 = Buffer.from(text, "utf8").toString("base64");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
+23
-13
@@ -1,6 +1,6 @@
|
||||
/** `#` writes project memory, `#g` global; `##+` stays a markdown heading; `\#` escapes and submits the literal `#`. */
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { PROJECT_MEMORY_FILE } from "../../memory/project.js";
|
||||
@@ -79,18 +79,28 @@ function appendBulletToFile(path: string, note: string, newFileHeader: string):
|
||||
const trimmed = note.trim();
|
||||
if (!trimmed) throw new Error("note body cannot be empty");
|
||||
const bullet = `- ${trimmed}\n`;
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${newFileHeader}${bullet}`, "utf8");
|
||||
return { path, created: true };
|
||||
}
|
||||
let prefix = "";
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
// One `a+` open covers both branches: O_APPEND lands every write
|
||||
// atomically at end-of-file (concurrent appenders interleave whole
|
||||
// bullets), O_CREAT creates the file when it's missing, and we use
|
||||
// `fstat().size === 0` as the "we just created it" signal to decide
|
||||
// whether to emit the file header. Single fd from open through
|
||||
// write — no path-based check between (CodeQL js/file-system-race).
|
||||
const fd = openSync(path, "a+");
|
||||
try {
|
||||
const existing = readFileSync(path, "utf8");
|
||||
if (existing.length > 0 && !existing.endsWith("\n")) prefix = "\n";
|
||||
} catch {
|
||||
// Unreadable but exists — let appendFileSync surface the real error.
|
||||
const stat = fstatSync(fd);
|
||||
if (stat.size === 0) {
|
||||
writeSync(fd, `${newFileHeader}${bullet}`);
|
||||
return { path, created: true };
|
||||
}
|
||||
// Existing file — peek the trailing byte to decide whether to
|
||||
// insert a leading newline. Same fd → no separate stat→read race.
|
||||
const tail = Buffer.alloc(1);
|
||||
readSync(fd, tail, 0, 1, stat.size - 1);
|
||||
const prefix = tail[0] !== 0x0a ? "\n" : "";
|
||||
writeSync(fd, `${prefix}${bullet}`);
|
||||
return { path, created: false };
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
appendFileSync(path, `${prefix}${bullet}`, "utf8");
|
||||
return { path, created: false };
|
||||
}
|
||||
@@ -6,13 +6,6 @@ import type { Scrollback } from "./useScrollback.js";
|
||||
|
||||
export type SlashOutcome = { kind: "consumed" } | { kind: "resubmit"; text: string };
|
||||
|
||||
interface ActiveLoopShape {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
}
|
||||
|
||||
export interface ApplySlashResultContext {
|
||||
log: Scrollback;
|
||||
stdoutWrite: (chunk: string) => void;
|
||||
@@ -20,10 +13,10 @@ export interface ApplySlashResultContext {
|
||||
syncPendingCount: () => void;
|
||||
session: string | null;
|
||||
codeModeOn: boolean;
|
||||
activeLoopRef: MutableRefObject<ActiveLoopShape | null>;
|
||||
isLoopActive: () => boolean;
|
||||
stopLoop: () => void;
|
||||
quitProcess: () => void;
|
||||
promptHistory: MutableRefObject<string[]>;
|
||||
pushHistory: (text: string) => void;
|
||||
/** The verbatim text the user typed; used for promptHistory bookkeeping. */
|
||||
text: string;
|
||||
}
|
||||
@@ -34,14 +27,12 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
// the process is exiting. Use quitProcess (process.exit) rather than
|
||||
// Ink's exit(): the singleton stdin reader keeps a `data` listener
|
||||
// attached, so exit() unmounts React but leaves the event loop alive.
|
||||
if (ctx.activeLoopRef.current) ctx.stopLoop();
|
||||
if (ctx.isLoopActive()) ctx.stopLoop();
|
||||
ctx.quitProcess();
|
||||
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);
|
||||
@@ -50,7 +41,7 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
clearPendingEdits(ctx.session);
|
||||
ctx.syncPendingCount();
|
||||
}
|
||||
if (ctx.activeLoopRef.current) ctx.stopLoop();
|
||||
if (ctx.isLoopActive()) ctx.stopLoop();
|
||||
return { kind: "consumed" };
|
||||
}
|
||||
if (result.info) {
|
||||
@@ -77,6 +68,6 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
if (result.resubmit) {
|
||||
return { kind: "resubmit", text: result.resubmit };
|
||||
}
|
||||
ctx.promptHistory.current.push(ctx.text);
|
||||
ctx.pushHistory(ctx.text);
|
||||
return { kind: "consumed" };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type MutableRefObject, useCallback } from "react";
|
||||
import {
|
||||
type ApplyResult,
|
||||
type EditBlock,
|
||||
type EditSnapshot,
|
||||
applyEditBlocks,
|
||||
snapshotBeforeEdits,
|
||||
} from "../../../code/edit-blocks.js";
|
||||
import { clearPendingEdits, savePendingEdits } from "../../../code/pending-edits.js";
|
||||
import { formatEditResults, partitionEdits } from "../edit-history.js";
|
||||
|
||||
export interface UseCodeModeResult {
|
||||
/** /apply callback. Empty `indices` means "all"; specific 1-based indices apply only those. */
|
||||
codeApply: (indices?: readonly number[]) => string;
|
||||
/** /discard callback. Same indices semantics as codeApply. */
|
||||
codeDiscard: (indices?: readonly number[]) => string;
|
||||
}
|
||||
|
||||
export interface UseCodeModeOptions {
|
||||
codeMode: boolean;
|
||||
pendingEdits: MutableRefObject<EditBlock[]>;
|
||||
currentRootDir: string;
|
||||
session: string | null;
|
||||
syncPendingCount: () => void;
|
||||
recordEdit: (
|
||||
source: string,
|
||||
blocks: readonly EditBlock[],
|
||||
results: readonly ApplyResult[],
|
||||
snaps: readonly EditSnapshot[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** Slash-command callbacks for `/apply` and `/discard` over the pending-edits queue. Owns the partition / snapshot / save / sync sequence; AppInner just forwards the strings to its log. */
|
||||
export function useCodeMode(opts: UseCodeModeOptions): UseCodeModeResult {
|
||||
const { codeMode, pendingEdits, currentRootDir, session, syncPendingCount, recordEdit } = opts;
|
||||
|
||||
const codeApply = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) {
|
||||
return "nothing pending — the model hasn't proposed edits since the last /apply or /discard.";
|
||||
}
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing applied. Use /apply with no args to commit them all.";
|
||||
}
|
||||
const snaps = snapshotBeforeEdits(selected, currentRootDir);
|
||||
const results = applyEditBlocks(selected, currentRootDir);
|
||||
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
if (anyApplied) recordEdit("review-apply", selected, results, snaps);
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? `\n▸ ${remaining.length} edit block(s) still pending — /apply or /discard to clear them.`
|
||||
: "";
|
||||
return formatEditResults(results) + tail;
|
||||
},
|
||||
[codeMode, currentRootDir, session, syncPendingCount, recordEdit, pendingEdits],
|
||||
);
|
||||
|
||||
const codeDiscard = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) return "nothing pending to discard.";
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing discarded.";
|
||||
}
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? ` (${remaining.length} block(s) still pending)`
|
||||
: ". Nothing was written to disk.";
|
||||
return `▸ discarded ${selected.length} pending edit block(s)${tail}`;
|
||||
},
|
||||
[session, syncPendingCount, pendingEdits],
|
||||
);
|
||||
|
||||
return { codeApply, codeDiscard };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export interface UseInputRecallResult {
|
||||
recallPrev: () => void;
|
||||
recallNext: () => void;
|
||||
pushHistory: (text: string) => void;
|
||||
/** Reset cursor to the "fresh input" position — call after a successful submit. */
|
||||
resetCursor: () => void;
|
||||
}
|
||||
|
||||
/** Bash-style ↑/↓ recall over a turn-local prompt history. Cursor is `useRef` so toggles don't re-render. */
|
||||
export function useInputRecall(setInput: (s: string) => void): UseInputRecallResult {
|
||||
const promptHistory = useRef<string[]>([]);
|
||||
const historyCursor = useRef<number>(-1);
|
||||
|
||||
const recallPrev = useCallback(() => {
|
||||
const hist = promptHistory.current;
|
||||
if (hist.length === 0) return;
|
||||
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(hist[hist.length - 1 - nextCursor] ?? "");
|
||||
}, [setInput]);
|
||||
|
||||
const recallNext = useCallback(() => {
|
||||
if (historyCursor.current < 0) return;
|
||||
const hist = promptHistory.current;
|
||||
const nextCursor = historyCursor.current - 1;
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
|
||||
}, [setInput]);
|
||||
|
||||
const pushHistory = useCallback((text: string) => {
|
||||
promptHistory.current.push(text);
|
||||
}, []);
|
||||
|
||||
const resetCursor = useCallback(() => {
|
||||
historyCursor.current = -1;
|
||||
}, []);
|
||||
|
||||
return { recallPrev, recallNext, pushHistory, resetCursor };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Scrollback } from "./useScrollback.js";
|
||||
|
||||
interface ActiveLoop {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
}
|
||||
|
||||
export interface ActiveLoopSnapshot {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
nextFireMs: number;
|
||||
}
|
||||
|
||||
export interface UseLoopModeResult {
|
||||
startLoop: (intervalMs: number, prompt: string) => void;
|
||||
stopLoop: () => void;
|
||||
/** Snapshot for the `/loop` (no-arg) status branch. Returns null when no loop is active. */
|
||||
getLoopStatus: () => ActiveLoopSnapshot | null;
|
||||
/** Cheap predicate — used by handleSubmit's cancel-on-user-input guard and by apply-slash-result. */
|
||||
isLoopActive: () => boolean;
|
||||
/** True only during the timer's `handleSubmit` invocation — tells handleSubmit's cancel guard to skip itself so the loop's own re-submit doesn't kill the loop. */
|
||||
isLoopFiring: () => boolean;
|
||||
/** Reset by handleSubmit at the top of every call so the firing flag is one-shot. */
|
||||
clearFiringFlag: () => void;
|
||||
/** Reactive state for the LoopStatusRow render — null when no loop is active. */
|
||||
activeLoop: ActiveLoop | null;
|
||||
}
|
||||
|
||||
export interface UseLoopModeOptions {
|
||||
log: Scrollback;
|
||||
busyRef: MutableRefObject<boolean>;
|
||||
/** Forward-ref to the latest `handleSubmit` — the closure shifts as state changes, so the timer dereferences fresh on each fire. */
|
||||
handleSubmitRef: MutableRefObject<((raw: string) => Promise<void>) | null>;
|
||||
}
|
||||
|
||||
/** Owns the active /loop config + its setTimeout-based scheduler. Re-issuing /loop replaces the slot; cancellation is centralized in stopLoop. */
|
||||
export function useLoopMode(opts: UseLoopModeOptions): UseLoopModeResult {
|
||||
const { log, busyRef, handleSubmitRef } = opts;
|
||||
const [activeLoop, setActiveLoop] = useState<ActiveLoop | null>(null);
|
||||
const activeLoopRef = useRef<ActiveLoop | null>(null);
|
||||
const loopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loopFiringRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
activeLoopRef.current = activeLoop;
|
||||
}, [activeLoop]);
|
||||
|
||||
const stopLoop = useCallback(() => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
setActiveLoop(null);
|
||||
log.pushInfo(`▸ loop stopped (after ${cur.iter} iter${cur.iter === 1 ? "" : "s"}).`);
|
||||
}, [log]);
|
||||
|
||||
const startLoop = useCallback((intervalMs: number, prompt: string) => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
setActiveLoop({
|
||||
prompt,
|
||||
intervalMs,
|
||||
nextFireAt: Date.now() + intervalMs,
|
||||
iter: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getLoopStatus = useCallback((): ActiveLoopSnapshot | null => {
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return null;
|
||||
return {
|
||||
prompt: cur.prompt,
|
||||
intervalMs: cur.intervalMs,
|
||||
iter: cur.iter,
|
||||
nextFireMs: Math.max(0, cur.nextFireAt - Date.now()),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isLoopActive = useCallback(() => activeLoopRef.current !== null, []);
|
||||
const isLoopFiring = useCallback(() => loopFiringRef.current, []);
|
||||
const clearFiringFlag = useCallback(() => {
|
||||
loopFiringRef.current = false;
|
||||
}, []);
|
||||
|
||||
// /loop scheduler. Re-runs whenever activeLoop's `nextFireAt` shifts —
|
||||
// either because startLoop set a fresh schedule or because a previous
|
||||
// firing bumped the next-fire time. Cleanup clears the in-flight
|
||||
// timer so a stopLoop / replacement doesn't leak a fire after cancel.
|
||||
useEffect(() => {
|
||||
if (!activeLoop) return;
|
||||
const delay = Math.max(0, activeLoop.nextFireAt - Date.now());
|
||||
const timer = setTimeout(async () => {
|
||||
loopTimerRef.current = null;
|
||||
// Skip the firing entirely when a prior turn is still running.
|
||||
// Re-arm in 1s so the loop catches up the moment busy clears,
|
||||
// rather than waiting a full interval after a slow turn.
|
||||
if (busyRef.current) {
|
||||
setActiveLoop((cur) => (cur ? { ...cur, nextFireAt: Date.now() + 1000 } : cur));
|
||||
return;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
const nextIter = cur.iter + 1;
|
||||
// Schedule the NEXT firing now (independent of how long this turn
|
||||
// takes). Keeps the cadence honest even when individual turns run
|
||||
// long.
|
||||
setActiveLoop((c) =>
|
||||
c ? { ...c, iter: nextIter, nextFireAt: Date.now() + cur.intervalMs } : c,
|
||||
);
|
||||
log.pushInfo(`▸ /loop iter ${nextIter} → ${cur.prompt}`);
|
||||
loopFiringRef.current = true;
|
||||
try {
|
||||
await handleSubmitRef.current?.(cur.prompt);
|
||||
} catch {
|
||||
// Persistent submission errors → kill the loop rather than spam
|
||||
// the screen. User can re-issue /loop once they fix the cause.
|
||||
stopLoop();
|
||||
} finally {
|
||||
loopFiringRef.current = false;
|
||||
}
|
||||
}, delay);
|
||||
loopTimerRef.current = timer;
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeLoop, stopLoop, log, busyRef, handleSubmitRef]);
|
||||
|
||||
return {
|
||||
startLoop,
|
||||
stopLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
activeLoop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { type MutableRefObject, useCallback, useEffect } from "react";
|
||||
|
||||
/** Ctrl+C / SIGINT → flush transcript + `process.exit(0)`. We call `process.exit` directly rather than Ink's `exit()` because the singleton stdin reader keeps a `data` listener attached — `exit()` would unmount the React tree but leave the event loop alive and the terminal would hang. */
|
||||
export function useQuit(transcriptRef: MutableRefObject<WriteStream | null>): () => void {
|
||||
const quitProcess = useCallback(() => {
|
||||
transcriptRef.current?.end();
|
||||
process.exit(0);
|
||||
}, [transcriptRef]);
|
||||
|
||||
useEffect(() => {
|
||||
process.on("SIGINT", quitProcess);
|
||||
return () => {
|
||||
process.off("SIGINT", quitProcess);
|
||||
};
|
||||
}, [quitProcess]);
|
||||
|
||||
return quitProcess;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export interface Scrollback {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
elapsedMs?: number;
|
||||
}): string;
|
||||
showPlan(args: {
|
||||
@@ -219,6 +220,7 @@ export function useScrollback(): Scrollback {
|
||||
cost: args.cost,
|
||||
sessionCost: args.sessionCost,
|
||||
balance: args.balance,
|
||||
balanceCurrency: args.balanceCurrency,
|
||||
elapsedMs: args.elapsedMs,
|
||||
});
|
||||
return id;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { type MutableRefObject, useCallback } from "react";
|
||||
import type { LoopEvent } from "../../../loop.js";
|
||||
import { recordFromLoopEvent, writeRecord } from "../../../transcript/log.js";
|
||||
|
||||
/** Returns a `LoopEvent` writer that no-ops when no transcript was opened. Wraps `recordFromLoopEvent` + `writeRecord` so callers don't carry the model/prefix metadata. */
|
||||
export function useTranscriptWriter(
|
||||
transcriptRef: MutableRefObject<WriteStream | null>,
|
||||
model: string,
|
||||
prefixHash: string,
|
||||
): (ev: LoopEvent) => void {
|
||||
return useCallback(
|
||||
(ev: LoopEvent) => {
|
||||
const stream = transcriptRef.current;
|
||||
if (!stream) return;
|
||||
writeRecord(stream, recordFromLoopEvent(ev, { model, prefixHash }));
|
||||
},
|
||||
[transcriptRef, model, prefixHash],
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -11,6 +11,7 @@ import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pil
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { useElapsedSeconds, useSlowTick, useTick } from "../ticker.js";
|
||||
import type { SubagentActivity } from "../useSubagent.js";
|
||||
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
@@ -112,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>
|
||||
@@ -132,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>
|
||||
);
|
||||
@@ -159,20 +167,8 @@ function subagentTitle(skillName: string | undefined, task: string): string {
|
||||
return `Sub-agent · ${short || "anonymous"}`;
|
||||
}
|
||||
|
||||
/** Live block for a running subagent — Card-styled, fixed row count for flicker-safety. */
|
||||
export function SubagentRow({
|
||||
activity,
|
||||
}: {
|
||||
activity: {
|
||||
task: string;
|
||||
iter: number;
|
||||
elapsedMs: number;
|
||||
skillName?: string;
|
||||
model?: string;
|
||||
phase?: "exploring" | "summarising";
|
||||
lastInner: { glyph: string; color: string; label: string; meta?: string } | null;
|
||||
};
|
||||
}) {
|
||||
/** Live block for a single in-flight subagent — rich layout, used when only one is running. */
|
||||
export function SubagentRow({ activity }: { activity: SubagentActivity }) {
|
||||
useTick();
|
||||
const seconds = (activity.elapsedMs / 1000).toFixed(1);
|
||||
const phase = subagentPhaseLabel(activity.phase, activity.iter, activity.elapsedMs);
|
||||
@@ -222,6 +218,78 @@ export function SubagentRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** 1 → rich; 2-max → compact rows; >max → compact + "+N more" fold. */
|
||||
export function SubagentLiveStack({
|
||||
activities,
|
||||
max = 3,
|
||||
}: {
|
||||
activities: ReadonlyArray<SubagentActivity>;
|
||||
max?: number;
|
||||
}) {
|
||||
const tick = useTick();
|
||||
if (activities.length === 0) return null;
|
||||
if (activities.length === 1) return <SubagentRow activity={activities[0]!} />;
|
||||
const visible = activities.slice(0, max);
|
||||
const overflow = activities.length - visible.length;
|
||||
const summarising = activities.filter((a) => a.phase === "summarising").length;
|
||||
const metaParts = [`${activities.length} running`];
|
||||
if (summarising > 0) metaParts.push(`${summarising} summarising`);
|
||||
return (
|
||||
<Card tone={CARD.subagent.color}>
|
||||
<CardHeader
|
||||
glyph="⌬"
|
||||
tone={CARD.subagent.color}
|
||||
title="subagents"
|
||||
titleColor={PILL_SECTION.plan.fg}
|
||||
titleBg={PILL_SECTION.plan.bg}
|
||||
subtitle={metaParts.join(" · ")}
|
||||
right={<Spinner kind="braille" color={CARD.subagent.color} />}
|
||||
/>
|
||||
{visible.map((a, i) => (
|
||||
<CompactSubagentLine key={a.runId} activity={a} tick={tick} index={i} />
|
||||
))}
|
||||
{overflow > 0 ? <Text color={FG.faint}>{` +${overflow} more running…`}</Text> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactSubagentLine({
|
||||
activity,
|
||||
tick,
|
||||
index,
|
||||
}: {
|
||||
activity: SubagentActivity;
|
||||
tick: number;
|
||||
index: number;
|
||||
}) {
|
||||
const summarising = activity.phase === "summarising";
|
||||
const spinnerFrame = SPINNER_FRAMES[(tick + index) % SPINNER_FRAMES.length] ?? "·";
|
||||
const glyph = summarising ? "▶" : spinnerFrame;
|
||||
const glyphColor = summarising ? TONE.brand : CARD.subagent.color;
|
||||
const seconds = (activity.elapsedMs / 1000).toFixed(1).padStart(5);
|
||||
const title = activity.skillName ?? truncate(activity.task, 28);
|
||||
const titlePadded = title.padEnd(28);
|
||||
const last = activity.lastInner;
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text color={glyphColor} bold>
|
||||
{` ${glyph} `}
|
||||
</Text>
|
||||
<Text color={FG.body}>{titlePadded}</Text>
|
||||
<Text color={FG.faint}>{` iter ${String(activity.iter).padStart(2)} · ${seconds}s · `}</Text>
|
||||
{last ? (
|
||||
<>
|
||||
<Text color={last.color}>{`${last.glyph} `}</Text>
|
||||
<Text color={FG.body}>{truncate(last.label, 18)}</Text>
|
||||
{last.meta ? <Text color={FG.faint}>{` ${last.meta}`}</Text> : null}
|
||||
</>
|
||||
) : (
|
||||
<Text color={FG.faint}>queued…</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
@@ -4,25 +4,17 @@ import React from "react";
|
||||
import { Countdown } from "../primitives/Countdown.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
import type { Mode, NetworkState, StatusBar } from "../state/state.js";
|
||||
import { FG, TONE, USD_TO_CNY } from "../theme/tokens.js";
|
||||
import { FG, TONE, balanceColor, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const RULE_PAD = 4;
|
||||
const RULE_MIN = 20;
|
||||
|
||||
function balanceColor(cny: number): string {
|
||||
if (cny < 5) return TONE.err;
|
||||
if (cny < 20) return TONE.warn;
|
||||
return TONE.brand;
|
||||
}
|
||||
|
||||
export function StatusRow(): React.ReactElement {
|
||||
const status = useAgentState((s) => s.status);
|
||||
const session = useAgentState((s) => s.session);
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const ruleWidth = Math.max(RULE_MIN, cols - RULE_PAD);
|
||||
const turnCny = status.cost * USD_TO_CNY;
|
||||
const sessionCny = status.sessionCost * USD_TO_CNY;
|
||||
const hasTurn = status.cost > 0;
|
||||
|
||||
return (
|
||||
@@ -48,16 +40,22 @@ export function StatusRow(): React.ReactElement {
|
||||
<Text bold color={TONE.brand}>
|
||||
{"▸ "}
|
||||
</Text>
|
||||
<Text bold color={FG.body}>{`¥${turnCny.toFixed(4)} turn`}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{`${formatCost(status.cost, status.balanceCurrency)} turn`}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
<Text color={FG.sub}>{`¥${sessionCny.toFixed(3)} session`}</Text>
|
||||
<Text
|
||||
color={FG.sub}
|
||||
>{`${formatCost(status.sessionCost, status.balanceCurrency, 3)} session`}</Text>
|
||||
{status.balance !== undefined && (
|
||||
<>
|
||||
<Sep />
|
||||
<Text color={FG.faint}>{"wallet "}</Text>
|
||||
<Text bold color={balanceColor(status.balance)}>{`¥${status.balance.toFixed(2)}`}</Text>
|
||||
<Text bold color={balanceColor(status.balance, status.balanceCurrency)}>
|
||||
{formatBalance(status.balance, status.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+33
-30
@@ -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>
|
||||
@@ -328,31 +327,29 @@ function FallbackTable({
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{headerCells.map((h, ci) => {
|
||||
const label = `${padToCells(h, labelPad - 2)}: `;
|
||||
const values = bodyCells.map((r) => r[ci] ?? "");
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: header cells positional
|
||||
<Box key={`fk-${ci}`} flexDirection="column">
|
||||
{values.map((v, ri) => {
|
||||
const lines = wrapToCells(v, valueCells);
|
||||
return lines.map((line, li) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fallback table lines are positional
|
||||
<Box key={`fv-${ri}-${li}`}>
|
||||
{li === 0 ? (
|
||||
<Text bold color={FG.sub}>
|
||||
{label}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>{padToCells("", labelPad)}</Text>
|
||||
)}
|
||||
<Text color={FG.body}>{line}</Text>
|
||||
</Box>
|
||||
));
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{bodyCells.map((row, ri) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: body rows positional
|
||||
<Box key={`fr-${ri}`} flexDirection="column">
|
||||
{ri > 0 ? <Text> </Text> : null}
|
||||
{headerCells.map((h, ci) => {
|
||||
const label = `${padToCells(h, labelPad - 2)}: `;
|
||||
const lines = wrapToCells(row[ci] ?? "", valueCells);
|
||||
return lines.map((line, li) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fallback table lines are positional
|
||||
<Box key={`fc-${ri}-${ci}-${li}`}>
|
||||
{li === 0 ? (
|
||||
<Text bold color={FG.sub}>
|
||||
{label}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>{padToCells("", labelPad)}</Text>
|
||||
)}
|
||||
<Text color={FG.body}>{line}</Text>
|
||||
</Box>
|
||||
));
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -370,12 +367,17 @@ function Inline({ tokens }: { tokens: Token[] }): React.ReactElement {
|
||||
const FILE_REF_RE = /\b([A-Za-z0-9_./@\-]+\.[A-Za-z0-9]{1,6})(?::(\d+)(?:-(\d+))?)?\b/g;
|
||||
const MENTION_RE = /(?<![A-Za-z0-9_])@([A-Za-z0-9_./\-]+\.[A-Za-z0-9]{1,6})/g;
|
||||
|
||||
function osc8(label: string, target: string, color: string): React.ReactElement {
|
||||
const open = `\x1b]8;;${target}\x07`;
|
||||
const close = "\x1b]8;;\x07";
|
||||
function looksLikeFileRef(path: string, hasLine: boolean): boolean {
|
||||
if (hasLine) return true;
|
||||
if (path.includes("/")) return true;
|
||||
const ext = path.split(".").pop() ?? "";
|
||||
return ext.length >= 2;
|
||||
}
|
||||
|
||||
function osc8(label: string, _target: string, color: string): React.ReactElement {
|
||||
return (
|
||||
<Text color={color} underline>
|
||||
{`${open}${label}${close}`}
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -402,6 +404,7 @@ function renderInlineText(raw: string): React.ReactElement {
|
||||
if (hits.some((h) => start < h.end && end > h.start)) continue;
|
||||
const path = m[1]!;
|
||||
const line = m[2];
|
||||
if (!looksLikeFileRef(path, line !== undefined)) continue;
|
||||
const target = line ? `file://${path}:${line}` : `file://${path}`;
|
||||
hits.push({ start, end, node: osc8(m[0], target, TONE.brand) });
|
||||
}
|
||||
|
||||
@@ -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,10 +77,17 @@ 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]]",
|
||||
summary: "list / run user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
|
||||
argsHint: "[list|show <name>|new <name>|<name> [args]]",
|
||||
summary: "list / run / scaffold user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
|
||||
},
|
||||
{
|
||||
cmd: "hooks",
|
||||
@@ -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)` };
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
type ResolvedHook,
|
||||
globalSettingsPath,
|
||||
projectSettingsPath,
|
||||
} from "../../../../hooks.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "../../../../telemetry/usage.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "../../../../version.js";
|
||||
} from "@/hooks.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "@/telemetry/usage.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "@/version.js";
|
||||
import { runDoctorChecks } from "../../../commands/doctor.js";
|
||||
import { renderDashboard } from "../../../commands/stats.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
@@ -189,13 +189,10 @@ const loop: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
exit,
|
||||
quit: exit,
|
||||
clear,
|
||||
new: resetLog,
|
||||
reset: resetLog,
|
||||
keys,
|
||||
help,
|
||||
"?": help,
|
||||
retry,
|
||||
loop,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const dashboard: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
fmtAgo,
|
||||
listCheckpoints,
|
||||
restoreCheckpoint,
|
||||
} from "../../../../code/checkpoints.js";
|
||||
import type { EditMode } from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
} from "@/code/checkpoints.js";
|
||||
import type { EditMode } from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { parseEditIndices } from "../../edit-history.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { runGitCommit, stripOuterQuotes } from "../helpers.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const INIT_PROMPT = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import type { JobRecord } from "../../../../tools/jobs.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { JobRecord } from "@/tools/jobs.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
function statusIcon(r: JobRecord): string {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
getSupportedLanguages,
|
||||
notifyLanguageChange,
|
||||
setLanguage,
|
||||
t,
|
||||
} from "../../../../i18n/index.js";
|
||||
import type { LanguageCode } from "../../../../i18n/types.js";
|
||||
import { getSupportedLanguages, notifyLanguageChange, setLanguage, t } from "@/i18n/index.js";
|
||||
import type { LanguageCode } from "@/i18n/types.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
@@ -30,5 +25,4 @@ export const handlers: Record<string, SlashHandler> = {
|
||||
|
||||
return { info: t("slash.language.success") };
|
||||
},
|
||||
lang: (args, loop, ctx) => handlers.language!(args, loop, ctx),
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import type { CacheFirstLoop } from "../../../../loop.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { CacheFirstLoop } from "@/loop.js";
|
||||
import { applyMcpAppend } from "../../mcp-append.js";
|
||||
import { toggleMcpDisabled } from "../../mcp-disable.js";
|
||||
import { slashHealthBadge } from "../../mcp-health.js";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import {
|
||||
PROJECT_MEMORY_FILE,
|
||||
memoryEnabled,
|
||||
readProjectMemory,
|
||||
} from "../../../../memory/project.js";
|
||||
import { type MemoryScope, MemoryStore } from "../../../../memory/user.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { PROJECT_MEMORY_FILE, memoryEnabled, readProjectMemory } from "@/memory/project.js";
|
||||
import { type MemoryScope, MemoryStore } from "@/memory/user.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { resolveMemoryTarget } from "../helpers.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { saveReasoningEffort } from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { saveReasoningEffort } from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { PRESETS } from "../../presets.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
@@ -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") };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import {
|
||||
DEEPSEEK_CONTEXT_TOKENS,
|
||||
DEEPSEEK_PRICING,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
} from "../../../../telemetry/stats.js";
|
||||
import { countTokens } from "../../../../tokenizer.js";
|
||||
} from "@/telemetry/stats.js";
|
||||
import { countTokens } from "@/tokenizer.js";
|
||||
import { computeCtxBreakdown } from "../../ctx-breakdown.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { compactNum, formatToolList } from "../helpers.js";
|
||||
@@ -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);
|
||||
@@ -208,7 +212,7 @@ const cost: SlashHandler = (args, loop, ctx) => {
|
||||
return {};
|
||||
};
|
||||
|
||||
function estimateCost(userText: string, loop: import("../../../../loop.js").CacheFirstLoop) {
|
||||
function estimateCost(userText: string, loop: import("@/loop.js").CacheFirstLoop) {
|
||||
const pricing = DEEPSEEK_PRICING[loop.model];
|
||||
if (!pricing) {
|
||||
return { info: t("handlers.observability.costNoPricing", { model: loop.model }) };
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
clearProjectShellAllowed,
|
||||
loadProjectShellAllowed,
|
||||
removeProjectShellAllowed,
|
||||
} from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { BUILTIN_ALLOWLIST } from "../../../../tools/shell.js";
|
||||
} from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { BUILTIN_ALLOWLIST } from "@/tools/shell.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const permissions: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { basename } from "node:path";
|
||||
import { listPlanArchives, loadPlanState, relativeTime } from "../../../../code/plan-store.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { listPlanArchives, loadPlanState, relativeTime } from "@/code/plan-store.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const plans: SlashHandler = (_args, loop) => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { probeOllama } from "../../../../index/semantic/embedding.js";
|
||||
import { t } from "../../../../index/semantic/i18n.js";
|
||||
import { findOllamaBinary } from "../../../../index/semantic/ollama-launcher.js";
|
||||
import { probeOllama } from "@/index/semantic/embedding.js";
|
||||
import { t } from "@/index/semantic/i18n.js";
|
||||
import { findOllamaBinary } from "@/index/semantic/ollama-launcher.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const semantic: SlashHandler = (_args, _loop, ctx) => {
|
||||
@@ -74,15 +74,24 @@ interface IndexSummary {
|
||||
/** 10MB read cap so `/semantic` stays snappy on very large repos. */
|
||||
async function readIndexMeta(rootDir: string): Promise<IndexSummary | null> {
|
||||
const dataPath = path.join(rootDir, ".reasonix", "semantic", "index.jsonl");
|
||||
let raw: string;
|
||||
try {
|
||||
const stat = await fs.stat(dataPath);
|
||||
if (stat.size > 10 * 1024 * 1024) {
|
||||
// For huge indexes, give an order-of-magnitude estimate from
|
||||
// file size (avg ~500 bytes/chunk in practice). Files won't
|
||||
// be available, so we report just the chunk approximation.
|
||||
return { chunks: Math.round(stat.size / 500), files: 0 };
|
||||
// Bind size check and content to the same fd so a concurrent
|
||||
// rebuild can't grow the file past the 10MB threshold between
|
||||
// stat and read (CodeQL js/file-system-race).
|
||||
const fh = await fs.open(dataPath, "r");
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
if (stat.size > 10 * 1024 * 1024) {
|
||||
// For huge indexes, give an order-of-magnitude estimate from
|
||||
// file size (avg ~500 bytes/chunk in practice). Files won't
|
||||
// be available, so we report just the chunk approximation.
|
||||
return { chunks: Math.round(stat.size / 500), files: 0 };
|
||||
}
|
||||
raw = await fh.readFile("utf8");
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
const raw = await fs.readFile(dataPath, "utf8");
|
||||
const seenPaths = new Set<string>();
|
||||
let chunks = 0;
|
||||
for (const line of raw.split("\n")) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { deleteSession, listSessions, renameSession } from "../../../../memory/session.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { deleteSession, listSessions, renameSession } from "@/memory/session.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const sessions: SlashHandler = () => ({ openSessionsPicker: true });
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { SkillStore } from "../../../../skills.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { SkillStore } from "@/skills.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const skill: SlashHandler = (args, _loop, ctx) => {
|
||||
const store = new SkillStore({ projectRoot: ctx.codeRoot });
|
||||
const sub = (args[0] ?? "").toLowerCase();
|
||||
|
||||
if (sub === "new" || sub === "init") {
|
||||
const name = args[1];
|
||||
if (!name) return { info: t("handlers.skill.newUsage") };
|
||||
const wantsGlobal = args.slice(2).includes("--global") || !ctx.codeRoot;
|
||||
const result = store.create(name, wantsGlobal ? "global" : "project");
|
||||
if ("error" in result) {
|
||||
return { info: t("handlers.skill.newError", { reason: result.error }) };
|
||||
}
|
||||
return { info: t("handlers.skill.newCreated", { name, path: result.path }) };
|
||||
}
|
||||
|
||||
if (sub === "" || sub === "list" || sub === "ls") {
|
||||
const skills = store.list();
|
||||
if (skills.length === 0) {
|
||||
@@ -17,7 +28,13 @@ const skill: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!store.hasProjectScope()) {
|
||||
lines.push(t("handlers.skill.listProjectOnly"));
|
||||
}
|
||||
lines.push("", t("handlers.skill.listFrontmatter"), t("handlers.skill.listInvoke"));
|
||||
lines.push(
|
||||
"",
|
||||
t("handlers.skill.listFrontmatter"),
|
||||
t("handlers.skill.listInvoke"),
|
||||
"",
|
||||
t("handlers.skill.listEmptyNewHint"),
|
||||
);
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
const lines = [t("handlers.skill.listHeader", { count: skills.length })];
|
||||
|
||||
@@ -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). */
|
||||
@@ -84,6 +86,7 @@ export interface SlashContext {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
elapsedMs?: number;
|
||||
}) => void;
|
||||
dispatch?: (event: import("../state/events.js").AgentEvent) => void;
|
||||
@@ -131,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 {
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface UsageCard extends CardBase {
|
||||
readonly cost: number;
|
||||
readonly sessionCost: number;
|
||||
readonly balance?: number;
|
||||
readonly balanceCurrency?: string;
|
||||
/** Wall-clock for the turn — surfaced as `· 1.2s` in the header meta. */
|
||||
readonly elapsedMs?: number;
|
||||
/** Auto-emitted per-turn cards render as a single dim row; /cost emits the full breakdown. */
|
||||
|
||||
@@ -117,10 +117,16 @@ const sessionUpdate = z.object({
|
||||
cost: z.number().optional(),
|
||||
sessionCost: z.number().optional(),
|
||||
balance: z.number().optional(),
|
||||
balanceCurrency: z.string().optional(),
|
||||
cacheHit: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
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"]),
|
||||
@@ -241,6 +247,7 @@ const usageShow = z.object({
|
||||
cost: z.number().nonnegative(),
|
||||
sessionCost: z.number().nonnegative(),
|
||||
balance: z.number().optional(),
|
||||
balanceCurrency: z.string().optional(),
|
||||
elapsedMs: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
@@ -316,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,
|
||||
@@ -250,6 +255,7 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
|
||||
cost: event.cost,
|
||||
sessionCost: event.sessionCost,
|
||||
balance: event.balance,
|
||||
balanceCurrency: event.balanceCurrency,
|
||||
elapsedMs: event.elapsedMs,
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface StatusBar {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
cacheHit: number;
|
||||
countdownSeconds?: number;
|
||||
recording?: { sizeBytes: number; events: number; path: string };
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -59,7 +59,32 @@ export type CardTone = keyof typeof CARD;
|
||||
/** DeepSeek prices in CNY; our internal table is USD divided by 7.2. Multiply back for display. */
|
||||
export const USD_TO_CNY = 7.2;
|
||||
|
||||
export function formatCNY(usd: number, fractionDigits = 4): string {
|
||||
const cny = usd * USD_TO_CNY;
|
||||
return `¥${cny.toFixed(fractionDigits)}`;
|
||||
const SYMBOL: Record<string, string> = { USD: "$", CNY: "¥" };
|
||||
|
||||
/** Format an amount already in `currency`. Undefined currency → CNY (matches pre-fix behavior). */
|
||||
export function formatBalance(
|
||||
amount: number,
|
||||
currency?: string,
|
||||
opts?: { fractionDigits?: number; label?: boolean },
|
||||
): string {
|
||||
const cur = currency ?? "CNY";
|
||||
const sym = SYMBOL[cur];
|
||||
const digits = opts?.fractionDigits ?? 2;
|
||||
const body = sym ? `${sym}${amount.toFixed(digits)}` : `${cur} ${amount.toFixed(digits)}`;
|
||||
return opts?.label ? `w ${body}` : body;
|
||||
}
|
||||
|
||||
/** Format an internal USD cost in the wallet's display currency. Undefined currency → CNY. */
|
||||
export function formatCost(costUsd: number, currency?: string, fractionDigits = 4): string {
|
||||
const cur = currency ?? "CNY";
|
||||
const amount = cur === "CNY" ? costUsd * USD_TO_CNY : costUsd;
|
||||
return formatBalance(amount, cur, { fractionDigits });
|
||||
}
|
||||
|
||||
/** Threshold color for a wallet balance. USD is converted to CNY before the threshold check. */
|
||||
export function balanceColor(amount: number, currency?: string): string {
|
||||
const cny = (currency ?? "CNY") === "USD" ? amount * USD_TO_CNY : amount;
|
||||
if (cny < 5) return TONE.err;
|
||||
if (cny < 20) return TONE.warn;
|
||||
return TONE.brand;
|
||||
}
|
||||
+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();
|
||||
|
||||
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user