Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9a1d0912d | ||
|
|
3f3b5e2fe7 | ||
|
|
0990491584 | ||
|
|
40628d0025 | ||
|
|
346bb35cb8 | ||
|
|
97a93cf4f5 | ||
|
|
d837ddaf43 | ||
|
|
c56cd9d9a0 | ||
|
|
db194cb833 | ||
|
|
d817f1b965 | ||
|
|
9ea76a3ef9 | ||
|
|
136ed9ff58 | ||
|
|
5dbcd1b73b | ||
|
|
447d1d7049 | ||
|
|
042d2c4f6a | ||
|
|
f8143f7098 | ||
|
|
6fcf3a6cba | ||
|
|
1934e2598c | ||
|
|
03804e6170 | ||
|
|
b3f0aee657 | ||
|
|
1ffa27975c | ||
|
|
d63509ad85 | ||
|
|
459a370b7c | ||
|
|
39e69c2cab | ||
|
|
0d89ab84ef | ||
|
|
ecb1bfb051 | ||
|
|
db2ca97eb0 | ||
|
|
47f207119f | ||
|
|
6ccca2be0b | ||
|
|
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 | ||
|
|
207d55be52 | ||
|
|
1ab4ebe971 | ||
|
|
767633d00e | ||
|
|
10c7d45ac3 | ||
|
|
b5f6e1e54c | ||
|
|
4e16baaa66 | ||
|
|
b85f1de3b1 | ||
|
|
d35e27d193 | ||
|
|
835c6b8f23 | ||
|
|
9b23390605 | ||
|
|
ba58eceb3c | ||
|
|
18860ac28e | ||
|
|
07a5f34d16 | ||
|
|
ee3f8c676c | ||
|
|
cebe2c3ba8 | ||
|
|
d24a6b5b38 | ||
|
|
8c49b38732 | ||
|
|
32a4818d76 | ||
|
|
fe074942f4 | ||
|
|
eb6291843a | ||
|
|
305bc641a2 | ||
|
|
557cfc12c7 | ||
|
|
10f1866c02 | ||
|
|
d632755f43 | ||
|
|
ea37106e03 | ||
|
|
eb7c9b5425 | ||
|
|
8681d158aa | ||
|
|
a621f17294 | ||
|
|
4b446e8640 | ||
|
|
9443f50955 | ||
|
|
56b94d0677 | ||
|
|
5ea5f76de6 |
No files matched your search
@@ -0,0 +1 @@
|
||||
github: esengine
|
||||
@@ -4,6 +4,10 @@ about: Something is broken
|
||||
labels: bug
|
||||
---
|
||||
|
||||
> **Screen flicker, garbled output, leftover artifacts, cursor jumping?**
|
||||
> Use the **Display / rendering issue** template instead — it asks for the
|
||||
> terminal-specific info we need to diagnose those.
|
||||
|
||||
**What happened**
|
||||
A clear and concise description.
|
||||
|
||||
@@ -14,10 +18,13 @@ What you expected to happen.
|
||||
Steps or minimal code that reproduces it.
|
||||
|
||||
**Environment**
|
||||
- Reasonix version:
|
||||
- Node version:
|
||||
- OS:
|
||||
- DeepSeek model (e.g. `deepseek-chat`, `deepseek-reasoner`):
|
||||
- Reasonix version (`reasonix --version`):
|
||||
- Node version (`node --version`):
|
||||
- OS (Windows 11 / macOS 14 / Ubuntu 24.04 / …):
|
||||
- Shell (bash, zsh, fish, PowerShell 7, PowerShell 5.1, cmd, …):
|
||||
- Terminal app (Windows Terminal, iTerm2, Alacritty, kitty, WezTerm, **VSCode integrated**, **Cursor integrated**, Hyper, …):
|
||||
- DeepSeek model (e.g. `deepseek-v4-flash`, `deepseek-v4-pro`):
|
||||
|
||||
**Logs / transcript**
|
||||
If using the CLI, attach the relevant chunk of `--transcript`.
|
||||
If using the CLI, attach the relevant chunk of `--transcript`, or run
|
||||
`reasonix doctor` and paste the output.
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: Display / rendering issue
|
||||
about: Screen flicker, garbled output, leftover artifacts, cursor jumping
|
||||
labels: bug, rendering
|
||||
---
|
||||
|
||||
> Display problems almost always come from the **terminal emulator**, not
|
||||
> the shell. Please fill the terminal section carefully — `bash vs PowerShell`
|
||||
> tells us very little; `VSCode integrated terminal vs Windows Terminal`
|
||||
> tells us everything.
|
||||
|
||||
**Symptom** (tick all that apply)
|
||||
- [ ] Whole screen flickers / flashes during streaming response
|
||||
- [ ] Lines tear or only half-redraw
|
||||
- [ ] Stale output left behind after a frame updates
|
||||
- [ ] Cursor jumps to wrong column or vanishes
|
||||
- [ ] Mojibake / wrong-width characters (e.g. `□`, half-width emoji)
|
||||
- [ ] Other (describe below)
|
||||
|
||||
**When it happens**
|
||||
- [ ] During assistant streaming (token-by-token output)
|
||||
- [ ] When tool cards expand / collapse
|
||||
- [ ] During scroll-up / scrollback
|
||||
- [ ] On terminal resize
|
||||
- [ ] On launch / on quit
|
||||
- [ ] Other (describe below)
|
||||
|
||||
**Terminal — the important part**
|
||||
|
||||
Where exactly are you running `reasonix`?
|
||||
|
||||
- [ ] **VSCode** integrated terminal — VSCode version: `?`
|
||||
- [ ] **Cursor** integrated terminal — Cursor version: `?`
|
||||
- [ ] **Windows Terminal** — version: `?`
|
||||
- [ ] **cmd.exe** (legacy console host)
|
||||
- [ ] **PowerShell ISE** (note: ISE doesn't support ANSI — most things will look broken)
|
||||
- [ ] **iTerm2** / **Terminal.app** / **Alacritty** / **kitty** / **WezTerm** / **Hyper**
|
||||
- [ ] tmux / screen / mosh — and inside which outer terminal? `?`
|
||||
- [ ] Other:
|
||||
|
||||
> 💡 **How to find your VSCode / Cursor version**
|
||||
> `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS).
|
||||
> Paste the whole panel — version + commit + Electron + xterm.js if shown.
|
||||
|
||||
**Diagnostic dump — copy/paste output**
|
||||
|
||||
Run **one** of the snippets below in the same terminal where you saw the
|
||||
issue, and paste the output here:
|
||||
|
||||
<details><summary>PowerShell (Windows)</summary>
|
||||
|
||||
```powershell
|
||||
reasonix --version; node --version
|
||||
$PSVersionTable.PSVersion.ToString()
|
||||
[System.Environment]::OSVersion.VersionString
|
||||
"TERM=$env:TERM"
|
||||
"TERM_PROGRAM=$env:TERM_PROGRAM"
|
||||
"TERM_PROGRAM_VERSION=$env:TERM_PROGRAM_VERSION"
|
||||
"COLORTERM=$env:COLORTERM"
|
||||
"WT_SESSION=$env:WT_SESSION"
|
||||
"VSCODE_INJECTION=$env:VSCODE_INJECTION"
|
||||
"WSL_DISTRO_NAME=$env:WSL_DISTRO_NAME"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>bash / zsh (macOS / Linux / WSL / Git Bash)</summary>
|
||||
|
||||
```bash
|
||||
reasonix --version; node --version
|
||||
uname -a
|
||||
echo "TERM=$TERM"
|
||||
echo "TERM_PROGRAM=$TERM_PROGRAM"
|
||||
echo "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION"
|
||||
echo "COLORTERM=$COLORTERM"
|
||||
echo "WT_SESSION=$WT_SESSION"
|
||||
echo "VSCODE_INJECTION=$VSCODE_INJECTION"
|
||||
echo "WSL_DISTRO_NAME=$WSL_DISTRO_NAME"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
```
|
||||
<paste output here>
|
||||
```
|
||||
|
||||
**VSCode / Cursor users only — terminal settings**
|
||||
|
||||
Open Settings (`Ctrl+,`), search `terminal.integrated.gpuAcceleration`,
|
||||
report current value: `auto` / `on` / `canvas` / `off` — `?`
|
||||
|
||||
Already tried any of:
|
||||
- [ ] Switching `gpuAcceleration` to a different value
|
||||
- [ ] Detaching the terminal (drag tab into its own window)
|
||||
- [ ] Running the same command in a non-VSCode terminal — did it still flicker? `yes / no`
|
||||
|
||||
**Reproduction**
|
||||
|
||||
Steps that reliably trigger it (commands run, files edited, was a tool
|
||||
streaming a long response, was the window being resized, …):
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Screen recording (strongly preferred)**
|
||||
|
||||
A 5–10s GIF or MP4 is worth 1000 words for rendering bugs. Drop it in
|
||||
this comment box — GitHub uploads attachments inline.
|
||||
@@ -32,8 +32,12 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test (vitest)
|
||||
run: npm test
|
||||
- name: Test (vitest + coverage)
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Coverage job summary
|
||||
if: always()
|
||||
run: node scripts/coverage-summary.mjs
|
||||
|
||||
- name: Build (tsup)
|
||||
run: npm run build
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript-typescript]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.stryker-tmp/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
@@ -32,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
|
||||
|
||||
@@ -3,6 +3,660 @@
|
||||
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.31.0] — 2026-05-08
|
||||
|
||||
**Headline:** a Mac user reported a DeepSeek 503 day where Reasonix
|
||||
showed a wall of raw `DeepSeek 503: <html>...` and they couldn't tell
|
||||
if our agent had crashed or the upstream API was down. Two threads of
|
||||
work fell out of that single bug: a friendly outage notice with a
|
||||
1.5s reachability probe to `/user/balance` (so we can say "DS main
|
||||
API answered, but /chat/completions is failing — their problem") and
|
||||
a full sweep of every hardcoded English string a Chinese user could
|
||||
hit. ~150 strings across 8 files moved into the `loop.*` / `errors.*`
|
||||
/ `app.*` / `hooks.*` / `summary.*` / `wizard.*` namespaces with
|
||||
zh-CN translations. The setup wizard now opens with a language picker
|
||||
defaulting to `detectSystemLanguage()` — for the case where
|
||||
`Intl.DateTimeFormat().resolvedOptions().locale` returns the wrong
|
||||
locale and a user shouldn't have to discover `/language` after
|
||||
finishing setup in English.
|
||||
|
||||
The other half of the release is dashboard parity work — picker
|
||||
modals (sessions / checkpoint / MCP marketplace), viewer modal for
|
||||
`/replay`, plus cockpit / budget gauge / model picker / loop control
|
||||
panel / `/pro` one-shot — closing buckets B-E of the #369 web-parity
|
||||
tracker.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(loop): friendly DeepSeek 5xx error with reachability probe.
|
||||
When the chat endpoint returns 5xx (after retry.ts has already
|
||||
retried 4× with backoff), `formatLoopError` now spawns a 1.5s
|
||||
`/user/balance` probe and renders one of three messages: no probe
|
||||
(generic outage notice), reachable (main API up but /chat dying),
|
||||
unreachable (DS or your network is down). All three say "this is
|
||||
a DeepSeek-side problem, not Reasonix" and link
|
||||
https://status.deepseek.com. Removes the misleading file header in
|
||||
`loop/errors.ts` that claimed retry.ts swallowed all 5xx — it
|
||||
doesn't, and never did. (#440)
|
||||
- feat(wizard): first-launch language picker + full i18n. New
|
||||
`language` step before `apiKey`, cursor defaults to
|
||||
`detectSystemLanguage()` marked `(detected)`. Selection saves
|
||||
immediately so all later wizard screens render in the chosen
|
||||
language. Re-running `reasonix setup` opens at the same step with
|
||||
the cursor on the saved language so Enter is a no-op. The wizard's
|
||||
~30 hardcoded strings (welcome, prompts, validation errors, MCP
|
||||
catalog hints, review labels, save errors, saved screen) all moved
|
||||
to a new `wizard.*` namespace with zh-CN. (#442)
|
||||
- feat(dashboard): picker modal protocol for web parity. New
|
||||
`picker` modal kind drives sessions, checkpoint, and MCP
|
||||
marketplace pickers from the same protocol. Closes the gap where
|
||||
TUI-only modals stayed inaccessible from `/dashboard`. (#417,
|
||||
#418, #419, #420)
|
||||
- feat(dashboard): viewer modal kind for `/replay`. Loads an archived
|
||||
plan into a read-only time-travel snapshot, mirrors the TUI replay
|
||||
experience. (#421)
|
||||
- feat(dashboard): cockpit tile + budget gauge + 14-day cost trend.
|
||||
At-a-glance current-session telemetry on the overview panel. (#431)
|
||||
- feat(dashboard): editable model picker in settings + `/pro`
|
||||
one-shot panel + loop control (start / stop / countdown). The
|
||||
settings tab is now the single place to flip model preset, arm
|
||||
`/pro` for the next turn, or start an autonomous loop without
|
||||
switching to the TUI. (#430, #432, #433)
|
||||
- feat(dashboard): server surface for `/pro` / `/budget` / `/model`
|
||||
/ `/loop`. POST endpoints under `/api/cockpit/*` carry the
|
||||
mutations the panels above need. (#429)
|
||||
|
||||
**i18n sweep:**
|
||||
|
||||
- i18n(loop/errors): localize DeepSeek error messages — context
|
||||
overflow, 401/402/422/400, 5xx (with the new reachability probe
|
||||
variants), reason prefixes for budget/aborted/context-guard/stuck.
|
||||
20 keys + zh-CN. (#444)
|
||||
- i18n(loop): 14 user-facing yields in `step()` — budget exhausted /
|
||||
80% warning, /pro armed, aborted-at-iter, tool-budget warning,
|
||||
preflight fold/no-fold, flash + auto escalation, storm-broken,
|
||||
history compaction (regular + aggressive), forcing-summary. The
|
||||
`loop.*` namespace had 7 dead keys defined but never wired —
|
||||
removed and replaced with 20 that match the actual yield shapes.
|
||||
(#445)
|
||||
- i18n(hooks/summary): hook outcome formatter (`hook PreToolUse/Bash
|
||||
\`cmd\` block (output truncated at 256KB)`) and the force-summary
|
||||
status / hallucinated-fallback / failed-fallback strings now go
|
||||
through `t()`. New `hooks.*` and `summary.*` namespaces. (#446)
|
||||
- i18n(app): ~26 hardcoded strings in `App.tsx` plus seven existing
|
||||
`ui.*` keys that had been declared but never called (same dead-key
|
||||
pattern as `loop.*`). New `app.*` namespace covers walk modal,
|
||||
edit-mode cycle (review/auto/yolo), edit gate, dashboard stopped,
|
||||
hash-memory note, bash-mode failures, hook header rows,
|
||||
@mentions / @url, shell confirm, checkpoint saved, plan
|
||||
continue / stop / revise. (#447)
|
||||
- i18n(slash): four lagging slash handlers — `web-search-engine.ts`
|
||||
was 0% localized, plus `mcp.ts` / `plans.ts` / `semantic.ts` had
|
||||
small gaps. ~22 new keys. (#448)
|
||||
- i18n(dashboard): translate the plan `idle` status pill — the
|
||||
`active` / `done` pills already used `t()` but the third branch
|
||||
was hardcoded English. (#443)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(search): honor abort during recursive fs scans — Esc during a
|
||||
large `search` tool call now exits promptly instead of finishing
|
||||
the walk. (#400)
|
||||
- fix(ui): refresh model badge on dashboard preset change and /pro
|
||||
turns — the header pill stayed stale across server-side
|
||||
preset/pro switches. (#403)
|
||||
- fix(permissions): match Windows project keys case-insensitively —
|
||||
the project allowlist hashed `C:\Foo` differently from `c:\foo`,
|
||||
causing entries to "disappear" depending on which case the cwd
|
||||
carried. (#402)
|
||||
- fix(prompt): inline short single-line pastes verbatim — the long-
|
||||
paste collapser was firing on tiny one-liners and burying them
|
||||
behind a "(N chars pasted)" placeholder. (#397)
|
||||
|
||||
**Tests / refactor / docs:**
|
||||
|
||||
- test(mcp): cover startup summary states (#396)
|
||||
- chore: improve loop.ts tests (#271)
|
||||
- refactor(ui): quiet chat-screen chrome — fewer always-on rows on
|
||||
the welcome card so the prompt stays close to the top. (#411)
|
||||
- docs(readme): canonical install + subcommand cheatsheet (#408)
|
||||
- docs(issues): split off display/rendering template, collect
|
||||
terminal host info inline. (#412)
|
||||
- docs(dashboard): PARITY.md audit — bucket E of #369. (#439)
|
||||
|
||||
## [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
|
||||
operators (`|`, `||`, `&&`, `;`) and the seven file redirect
|
||||
operators (`>`, `>>`, `<`, `2>`, `2>>`, `2>&1`, `&>`). Parsed and
|
||||
spawned natively — no shell is invoked, so semantics are identical
|
||||
on Windows / macOS / Linux; PowerShell 5.1's `&&` parse error and
|
||||
the object-vs-bytes pipe gap are sidestepped. Each chain segment is
|
||||
allowlist-checked independently, so `git status | grep main` now
|
||||
auto-runs when both halves are individually allowed. Driven by
|
||||
discussion #231.
|
||||
|
||||
**Shell:**
|
||||
|
||||
- feat(shell): support `|`, `||`, `&&`, `;` chain operators in
|
||||
`run_command` via split-and-spawn. The chain is segmented at
|
||||
whitespace-bounded operators (preserves embedded `&` / `|` inside
|
||||
arg values like `--flag=1&2`), each segment runs through the
|
||||
existing lenient tokenizer, and segments are executed with proper
|
||||
short-circuit semantics for `&&` / `||`. Each segment hits the
|
||||
allowlist independently — `git status | grep main` runs when both
|
||||
halves are allowed individually. (#233, #234)
|
||||
- feat(shell): support file redirects in `run_command` — `>` (truncate),
|
||||
`>>` (append), `<` (stdin from file), `2>` (stderr truncate), `2>>`
|
||||
(stderr append), `2>&1` (merge stderr into wherever stdout points),
|
||||
`&>` (both → file). Targets resolve relative to the project root.
|
||||
Mid-pipe `2>&1` correctly merges stderr into the next segment's
|
||||
stdin without truncating on stdout-end. (#235)
|
||||
- fix(shell): chain parser stays consistent with the project's
|
||||
long-standing lenient tokenizer — `cargo run -- --flag=1&2` and
|
||||
similar embedded-operator args stay literal instead of getting
|
||||
POSIX-strict-rejected. shell-quote dependency dropped;
|
||||
`splitOnChainOps` is whitespace-bounded like the existing
|
||||
`detectShellOperator`. (#234)
|
||||
|
||||
## [0.24.1] — 2026-05-04
|
||||
|
||||
**Headline:** Two TUI fixes on top of the 0.24.0 cell-diff renderer.
|
||||
|
||||
@@ -1,123 +1,248 @@
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Reasonix — DeepSeek-native agent framework" width="640"/>
|
||||
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> · <a href="./README.zh-CN.md">简体中文</a> · <a href="https://esengine.github.io/reasonix/">Website</a>
|
||||
<strong>English</strong>
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">Architecture</a>
|
||||
·
|
||||
<a href="./benchmarks/">Benchmarks</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg" alt="npm version"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/actions/workflows/ci.yml"><img src="https://github.com/esengine/reasonix/actions/workflows/ci.yml/badge.svg" alt="CI"/></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg" alt="license"/></a>
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg" alt="downloads"/></a>
|
||||
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg" alt="node"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat&logo=github&label=stars" alt="GitHub stars"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?logo=github&label=discussions" alt="Discussions"/></a>
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" alt="npm version"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/reasonix/ci.yml?style=flat-square&label=ci&color=0d1117&labelColor=161b22" alt="CI"/></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" alt="license"/></a>
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" alt="downloads"/></a>
|
||||
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" alt="node"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22&logo=github" alt="GitHub stars"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22&logo=github" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22&logo=github" alt="Discussions"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
|
||||
<p align="center">Engineered around prefix-cache stability — so token costs stay low across long sessions, and you can leave it running.</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<strong>A DeepSeek-native AI coding agent for your terminal.</strong> Engineered around DeepSeek's prefix-cache, so the savings are real and the loop stays cheap enough to leave on.
|
||||
<img src="docs/assets/hero-terminal.svg" alt="Reasonix code mode — assistant proposes a SEARCH/REPLACE edit; nothing on disk until /apply" width="860"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-stats.svg" alt="94% live prefix-cache hit · ~30× cheaper per task vs Claude Code · MIT terminal-native" width="860"/>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
---
|
||||
> [!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.
|
||||
|
||||
## Quick start
|
||||
> [!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
|
||||
|
||||
```bash
|
||||
cd my-project
|
||||
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.
|
||||
|
||||
`npx` is the recommended path — no global install, always picks up the latest version. If you'll use Reasonix daily and want `reasonix` on your `PATH`, run `reasonix update` once and it'll do the `npm install -g` for you.
|
||||
|
||||
### Subcommand cheatsheet
|
||||
|
||||
| Command | When to use |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | Coding agent rooted at a project. **Start here.** |
|
||||
| `reasonix chat` | Plain chat — no filesystem tools, just a conversation with persisted history. |
|
||||
| `reasonix run "task"` | One-shot, streams the answer to stdout. Good for shell pipes. |
|
||||
| `reasonix doctor` | Environment health check (Node version, API key, MCP wiring). |
|
||||
| `reasonix update` | Upgrade Reasonix itself. |
|
||||
|
||||
Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`) are listed in `reasonix --help` and on the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
|
||||
|
||||
**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
|
||||
|
||||
The loop is organized around four pillars. Each one solves a problem generic agent frameworks don't even see — because they were designed for a different cache mechanic.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-terminal.svg" alt="Reasonix code mode — assistant proposes a SEARCH/REPLACE edit; nothing on disk until /apply" width="860"/>
|
||||
<a href="./docs/ARCHITECTURE.md"><img src="docs/assets/pillars.svg" alt="Reasonix four pillars — Cache-first loop, R1 thought harvesting, Tool-call repair, Cost control" width="880"/></a>
|
||||
</p>
|
||||
|
||||
Requires Node ≥ 22. Tested on macOS, Linux, and Windows (PowerShell, Git Bash, Windows Terminal). Get a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
|
||||
<sub align="center">
|
||||
|
||||
---
|
||||
Click any card to read the full architecture writeup → [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2](./docs/ARCHITECTURE.md#pillar-2--r1-thought-harvesting-opt-in) · [Pillar 3](./docs/ARCHITECTURE.md#pillar-3--tool-call-repair) · [Pillar 4](./docs/ARCHITECTURE.md#pillar-4--cost-control-v06)
|
||||
|
||||
</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
## Capabilities
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/feature-grid.svg" alt="Reasonix capabilities — cell-diff renderer, MCP, plan mode, permissions, dashboard, persistent sessions, hooks/skills/memory, semantic search, auto-checkpoints, /effort knob, transcript replay, event log" width="880"/>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## How it compares
|
||||
|
||||
| | Reasonix | Claude Code | Cursor | Aider |
|
||||
|-----------------------------------|------------------|-----------------|--------------------|------------------|
|
||||
| Backend | DeepSeek V4 | Anthropic | OpenAI / Anthropic | any (OpenRouter) |
|
||||
| **Cost / typical task** | **~¥0.01–0.04** | ~¥0.40–4 | ¥150/mo + usage | varies |
|
||||
| License | **MIT** | closed | closed | Apache 2 |
|
||||
| **DeepSeek prefix-cache hit** | **94%** (live) | n/a | n/a | ~33% (baseline) |
|
||||
| Embedded web dashboard | yes | — | n/a (IDE) | — |
|
||||
| Persistent per-workspace sessions | yes | partial | n/a | — |
|
||||
| | Reasonix | Claude Code | Cursor | Aider |
|
||||
|-----------------------------------|------------------|-------------------|---------------------|--------------------|
|
||||
| Backend | DeepSeek | Anthropic | OpenAI / Anthropic | any (OpenRouter) |
|
||||
| License | **MIT** | closed | closed | Apache 2 |
|
||||
| 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 |
|
||||
|
||||
Plan mode, edit review, MCP, skills, hooks, and sandboxing are all `yes` for Reasonix and most peers — see the feature grid below for what they actually do here.
|
||||
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.
|
||||
|
||||
Numbers from `benchmarks/tau-bench-lite` (8 multi-turn tasks × 3 repeats, live `deepseek-chat`). [Committed transcripts →](./benchmarks/)
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><strong>Why DeepSeek-only? — the cache economics</strong></summary>
|
||||
## Documentation
|
||||
|
||||
Cheap tokens alone is half the story. DeepSeek's prefix-cache is **byte-stable**: the cache fingerprints from byte 0 of the prompt. Reasonix's loop is engineered around that — append-only growth, no re-ordering, no marker-based compaction — so the cache prefix survives every tool call.
|
||||
- [**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/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)
|
||||
|
||||
By comparison, Claude Code is built around Anthropic's `cache_control` markers (a fundamentally different mechanic). Pointing it at DeepSeek's Anthropic-compat endpoint keeps the cheap tokens but loses the cache hits — markers are ignored, and the underlying prefix isn't byte-stable. Generic-backend tools (Aider / Cline / Continue) hit the same wall from the other direction: their compaction patterns destroy byte stability.
|
||||
<br/>
|
||||
|
||||
At DeepSeek's pricing — $0.07/Mtok uncached, $0.014/Mtok cached — **the difference between 50% and 94% hit is roughly 2.5× on input cost alone.** Same model, same API; the loop's invariants are what changed.
|
||||
## Community
|
||||
|
||||
A few DeepSeek-specific fixes generic loops miss:
|
||||
> [!NOTE]
|
||||
> Reasonix is open source and community-developed. The contributors wall below isn't decoration — every avatar is a real PR that shipped.
|
||||
|
||||
| Generic loops assume | DeepSeek actually does | Reasonix's fix |
|
||||
|---|---|---|
|
||||
| Reasoning emitted as a structured `thinking` block | R1 sometimes leaks tool-call JSON inside `<think>` tags | a `scavenge` pass that pulls escaped tool calls back out |
|
||||
| Tool schemas validated strictly | DeepSeek silently drops deeply-nested object/array params | auto-flatten — nested params get rewritten to single-level prefixed names |
|
||||
| Tool-call args are well-formed JSON | DeepSeek occasionally produces `string="false"` and other malformed fragments | dedicated `ToolCallRepair` heals the common shapes before dispatch |
|
||||
| Reasoning depth tuned via system-level switches | V4 exposes a `reasoning_effort` knob (`max` / `high`) | `/effort` slash + `--effort` flag for cheap turns |
|
||||
Scoped starter tickets — each with background, code pointers, acceptance criteria, and hints — live under the [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) label. Pick anything open.
|
||||
|
||||
Cache stability isn't a feature you turn on; it's an invariant the loop is designed around. That's the entire reason Reasonix is DeepSeek-only.
|
||||
**Open Discussions — opinions wanted:**
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## What's in the box
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/feature-grid.svg" alt="Feature grid — cache-first loop, plan mode, MCP first-class, sessions and dashboard, hooks, memory and skills" width="860"/>
|
||||
</p>
|
||||
|
||||
Permissions (`allow` / `ask` / `deny`), tool-call repair (flatten · scavenge · truncation · storm), and `/effort` for cheap turns round out the loop. [Architecture →](./docs/ARCHITECTURE.md) · [Dashboard mockup →](https://esengine.github.io/reasonix/design/agent-dashboard.html) · [TUI mockup →](https://esengine.github.io/reasonix/design/agent-tui-terminal.html) · [Website →](https://esengine.github.io/reasonix/)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Reasonix is solo-maintained but designed to grow. Scoped starter tickets — each with background, code pointers, acceptance criteria, and hints — live under the [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) label. Pick anything open.
|
||||
|
||||
**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?
|
||||
|
||||
**Before your first PR**: read [`CONTRIBUTING.md`](./CONTRIBUTING.md) — short, strict project rules (comments, errors, libraries-over-hand-rolled). `tests/comment-policy.test.ts` enforces the comment ones; `npm run verify` is the pre-push gate. By participating you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md). Security issues → [SECURITY.md](./SECURITY.md).
|
||||
**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:
|
||||
|
||||
### Contributors
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/reasonix/discussions/categories/show-and-tell">
|
||||
<img src="https://img.shields.io/badge/REASONIX-📣%20ADVOCATE-c4b5fd?style=for-the-badge&labelColor=0d1117" alt="Reasonix Advocate badge — earned by sustained advocates"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/reasonix" alt="Contributors to esengine/reasonix"/>
|
||||
</a>
|
||||
**Before your first PR**: read [`CONTRIBUTING.md`](./CONTRIBUTING.md) — short, strict rules (comments, errors, libraries-over-hand-rolled). `tests/comment-policy.test.ts` enforces the comment ones; `npm run verify` is the pre-push gate. By participating you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md). Security issues → [SECURITY.md](./SECURITY.md).
|
||||
|
||||
---
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/reasonix&max=100&columns=12" alt="Contributors to esengine/reasonix" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Multi-provider flexibility.** DeepSeek-only on purpose — every layer is tuned around DeepSeek's specific cache mechanic and pricing. Coupling to one backend is the feature.
|
||||
- **IDE integration.** Terminal-first; the diff lives in `git diff`, the file tree in `ls`. The dashboard is a companion, not a Cursor replacement.
|
||||
- **Hardest-leaderboard reasoning.** Claude Opus still wins some benchmarks. DeepSeek V4 is competitive on coding; if your work is "solve this PhD proof" rather than "fix this auth bug," start with Claude.
|
||||
> [!IMPORTANT]
|
||||
> Reasonix is opinionated. Some things it deliberately *doesn't* do — listed here so you can pick the right tool for your work.
|
||||
|
||||
- **Multi-provider flexibility.** DeepSeek-only on purpose. Coupling to one backend is the feature, not a limitation.
|
||||
- **IDE integration.** Terminal-first. The diff lives in `git diff`, the file tree in `ls`. The dashboard is a companion, not a Cursor replacement.
|
||||
- **Hardest-leaderboard reasoning.** Claude Opus still wins some benchmarks. DeepSeek is competitive on coding; if your work is "solve this PhD proof" rather than "fix this auth bug," start with Claude.
|
||||
- **Air-gapped / fully-free.** Reasonix needs a paid DeepSeek API key. For air-gapped or zero-cost runs see Aider + Ollama or [Continue](https://continue.dev).
|
||||
|
||||
<br/>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=esengine%2Freasonix&type=timeline&logscale=&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&theme=dark&logscale&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&logscale&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&logscale&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](./LICENSE).
|
||||
<p align="center">
|
||||
<sub>MIT — see <a href="./LICENSE">LICENSE</a></sub>
|
||||
<br/>
|
||||
<sub>Built by the community at <a href="https://github.com/esengine/reasonix/graphs/contributors">esengine/reasonix</a></sub>
|
||||
</p>
|
||||
@@ -1,123 +1,200 @@
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Reasonix — DeepSeek 原生的 agent 框架" width="640"/>
|
||||
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> · <strong>简体中文</strong> · <a href="https://esengine.github.io/reasonix/">官方网站</a>
|
||||
<a href="./README.md">English</a>
|
||||
·
|
||||
<strong>简体中文</strong>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">架构文档</a>
|
||||
·
|
||||
<a href="./benchmarks/">基准测试</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg" alt="npm version"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/actions/workflows/ci.yml"><img src="https://github.com/esengine/reasonix/actions/workflows/ci.yml/badge.svg" alt="CI"/></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg" alt="license"/></a>
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg" alt="downloads"/></a>
|
||||
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg" alt="node"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat&logo=github&label=stars" alt="GitHub stars"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?logo=github&label=discussions" alt="Discussions"/></a>
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" alt="npm version"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/reasonix/ci.yml?style=flat-square&label=ci&color=0d1117&labelColor=161b22" alt="CI"/></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" alt="license"/></a>
|
||||
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" alt="downloads"/></a>
|
||||
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22" alt="node"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22&logo=github" alt="GitHub stars"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22&logo=github" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?style=flat-square&color=0d1117&labelColor=161b22&logo=github" alt="Discussions"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">DeepSeek 原生的终端 AI 编程代理。</h3>
|
||||
<p align="center">围绕前缀缓存稳定性设计 —— 长会话下 token 成本始终低位运行,可以一直开着。</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<strong>DeepSeek 原生的终端 AI 编程代理。</strong> 围绕 DeepSeek 的前缀缓存机制打造——省钱是真省,loop 便宜到可以一直开着。
|
||||
<img src="docs/assets/hero-terminal.zh-CN.svg" alt="Reasonix code 模式预览 — 助手提出 SEARCH/REPLACE 编辑,未 /apply 不落盘" width="860"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-stats.zh-CN.svg" alt="94% 实测前缀缓存命中 · 单任务比 Claude Code 便宜 ~30 倍 · MIT 终端原生" width="860"/>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
---
|
||||
> [!TIP]
|
||||
> **缓存稳定不是开关,而是循环要围绕设计的不变量。** 这就是 Reasonix 只支持 DeepSeek 的根本原因 —— 每一层都为 DeepSeek 字节稳定的前缀缓存机制调过。
|
||||
|
||||
## 快速上手
|
||||
<br/>
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd my-project
|
||||
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`。
|
||||
|
||||
`npx` 是推荐路径 —— 不用全局安装,每次都拿到最新版本。如果你天天用、想把 `reasonix` 装到 `PATH` 上,跑一次 `reasonix update` 就行,它会替你跑 `npm install -g`。
|
||||
|
||||
### 子命令速查
|
||||
|
||||
| 命令 | 适用场景 |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | 锁在某个项目根目录的编码 agent。**先用这个。** |
|
||||
| `reasonix chat` | 纯聊天 —— 不挂文件系统工具,只是带历史的对话。 |
|
||||
| `reasonix run "task"` | 一次性,把答案直接流到 stdout。适合 shell 管道。 |
|
||||
| `reasonix doctor` | 环境体检(Node 版本、API Key、MCP 接线)。 |
|
||||
| `reasonix update` | 升级 Reasonix 本身。 |
|
||||
|
||||
其他子命令(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)见 `reasonix --help` 和 [CLI 参考](https://esengine.github.io/DeepSeek-Reasonix/#cli)。
|
||||
|
||||
**在其他目录工作:** 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 的不同之处
|
||||
|
||||
整个循环围绕四根支柱组织。每一根解决的都是通用 agent 框架根本看不见的问题 —— 因为它们是为另一种缓存机制设计的。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-terminal.zh-CN.svg" alt="Reasonix code 模式预览 — 助手提出 SEARCH/REPLACE 编辑,未 /apply 不落盘" width="860"/>
|
||||
<a href="./docs/ARCHITECTURE.md"><img src="docs/assets/pillars.zh-CN.svg" alt="Reasonix 四大支柱 — 缓存优先循环、R1 思维提取、工具调用修复、成本控制" width="880"/></a>
|
||||
</p>
|
||||
|
||||
要求 Node ≥ 22。已在 macOS、Linux、Windows(PowerShell · Git Bash · Windows Terminal)测过。[去拿 DeepSeek API Key →](https://platform.deepseek.com/api_keys) · 完整 flag 看 `reasonix code --help`。
|
||||
<sub align="center">
|
||||
|
||||
---
|
||||
各支柱完整说明 → [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2](./docs/ARCHITECTURE.md#pillar-2--r1-thought-harvesting-opt-in) · [Pillar 3](./docs/ARCHITECTURE.md#pillar-3--tool-call-repair) · [Pillar 4](./docs/ARCHITECTURE.md#pillar-4--cost-control-v06)
|
||||
|
||||
</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
## 能力一览
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/feature-grid.zh-CN.svg" alt="Reasonix 能力一览 — cell-diff 渲染器、MCP、计划模式、权限、仪表盘、持久化会话、Hooks/Skills/Memory、语义检索、自动 checkpoint、/effort 旋钮、transcript 重放、事件日志" width="880"/>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## 横向对比
|
||||
|
||||
| | Reasonix | Claude Code | Cursor | Aider |
|
||||
|----------------------------|-------------------|-----------------|--------------------|-------------------|
|
||||
| 后端 | DeepSeek V4 | Anthropic | OpenAI / Anthropic | 任意(OpenRouter)|
|
||||
| **单任务成本** | **~¥0.01–0.04** | ~¥0.40–4 | ¥150/月 + 用量 | 不一 |
|
||||
| 协议 | **MIT** | 闭源 | 闭源 | Apache 2 |
|
||||
| **DeepSeek 前缀缓存命中** | **94%**(实测) | 不适用 | 不适用 | ~33%(基线) |
|
||||
| 内嵌 web 仪表盘 | 支持 | — | 不适用 (IDE) | — |
|
||||
| 持久化的工作区会话 | 支持 | 部分 | 不适用 | — |
|
||||
| | Reasonix | Claude Code | Cursor | Aider |
|
||||
|----------------------------|-------------------|-------------------|---------------------|--------------------|
|
||||
| 后端 | DeepSeek | Anthropic | OpenAI / Anthropic | 任意(OpenRouter) |
|
||||
| 协议 | **MIT** | 闭源 | 闭源 | Apache 2 |
|
||||
| 单任务成本 | **低** | 高 | 订阅 + 用量 | 不一 |
|
||||
| DeepSeek 前缀缓存 | **专门工程化** | 不适用 | 不适用 | 偶发命中 |
|
||||
| 内嵌 web 仪表盘 | 支持 | — | 不适用 (IDE) | — |
|
||||
| 持久化的工作区会话 | 支持 | 部分 | 不适用 | — |
|
||||
| 计划模式 · MCP · Hooks | 支持 | 支持 | 支持 | 部分 |
|
||||
| 开放社区共建 | 支持 | — | — | 支持 |
|
||||
|
||||
计划模式、编辑审查、MCP、skill、Hooks、沙箱在 Reasonix 和大多数同类里都是"支持"——具体怎么做的看下面的功能一览。
|
||||
实测缓存命中率、成本、方法论看 [`benchmarks/`](./benchmarks/) —— 这些数会随模型定价变化,所以归在 harness 里,不进 README。
|
||||
|
||||
数据来自 `benchmarks/tau-bench-lite`(8 个多轮任务 × 3 次重放,真实 `deepseek-chat`)。[完整 transcript →](./benchmarks/)
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><strong>为什么只支持 DeepSeek?— 缓存经济学</strong></summary>
|
||||
## 文档
|
||||
|
||||
便宜的 token 只是故事的一半。DeepSeek 的前缀缓存是**字节稳定**的:缓存指纹从 prompt 第 0 字节开始算。Reasonix 整个循环都围绕这一点设计——只追加、不重排、不做基于标记的 compaction,所以缓存前缀能跨过每一次工具调用存活下来。
|
||||
- [**架构**](./docs/ARCHITECTURE.md) —— 四大支柱、缓存优先循环、思维提取、脚手架
|
||||
- [**基准测试**](./benchmarks/) —— τ-bench-lite harness、transcript、成本方法论
|
||||
- [**官方网站**](https://esengine.github.io/DeepSeek-Reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
|
||||
- [**贡献指南**](./CONTRIBUTING.md) —— 注释规则、错误处理、用现成库不手写
|
||||
- [**行为准则**](./CODE_OF_CONDUCT.md) · [**安全策略**](./SECURITY.md)
|
||||
|
||||
对比一下:Claude Code 是围绕 Anthropic 的 `cache_control` 标记构建的(完全不同的机制)。把 Claude Code 指向 DeepSeek 的 Anthropic 兼容端点,能拿到便宜的 token,但缓存命中没了——标记被忽略,底下的前缀本来就不字节稳定。通用后端工具(Aider / Cline / Continue)从另一个方向撞上同一堵墙:它们的 compaction 模式会破坏字节稳定。
|
||||
<br/>
|
||||
|
||||
按 DeepSeek 的定价 —— $0.07/Mtok 未命中、$0.014/Mtok 命中 —— **50% 命中和 94% 命中之间的差距,光是输入成本就大约 2.5 倍。** 同模型、同 API;变的只是循环本身的不变量。
|
||||
## 社区
|
||||
|
||||
通用循环漏掉的几个 DeepSeek 专属修复:
|
||||
> [!NOTE]
|
||||
> Reasonix 是开源、社区共建的项目。下面贡献者墙不是装饰 —— 每一个头像都对应一次真实合并的 PR。
|
||||
|
||||
| 通用循环假设的 | DeepSeek 实际表现 | Reasonix 的处理 |
|
||||
|---|---|---|
|
||||
| reasoning 在结构化的 `thinking` 块里 | R1 偶尔把 tool-call JSON 漏在 `<think>` 标签里 | 一个 `scavenge` pass 把逃逸的 tool call 拉回来 |
|
||||
| 工具 schema 严格校验 | DeepSeek 会静默丢掉深层嵌套的 object/array 参数 | 自动 flatten——嵌套参数被改写成单层带前缀的名字 |
|
||||
| tool-call 参数是合法 JSON | DeepSeek 偶尔吐 `string="false"` 之类的破碎片段 | 专门的 `ToolCallRepair` 在 dispatch 前把常见形状修好 |
|
||||
| reasoning 深度靠系统级开关调 | V4 暴露了 `reasoning_effort` 旋钮(`max` / `high`) | `/effort` 斜杠 + `--effort` flag,便宜回合可以降档 |
|
||||
|
||||
缓存稳定不是个开关,是循环要围绕设计的不变量。这就是 Reasonix 只支持 DeepSeek 的根本原因。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 功能一览
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/feature-grid.zh-CN.svg" alt="功能一览 — 缓存优先循环、计划模式、MCP 一等公民、会话与仪表盘、Hooks、Memory 与 Skills" width="860"/>
|
||||
</p>
|
||||
|
||||
权限系统(`allow` / `ask` / `deny`)、tool-call repair(flatten · scavenge · truncation · storm)、`/effort` 给便宜回合降档——一起把整个 loop 兜起来。[架构文档 →](./docs/ARCHITECTURE.md) · [Dashboard 设计稿 →](https://esengine.github.io/reasonix/design/agent-dashboard.html) · [TUI 设计稿 →](https://esengine.github.io/reasonix/design/agent-tui-terminal.html) · [官网 →](https://esengine.github.io/reasonix/)
|
||||
|
||||
---
|
||||
|
||||
## 参与贡献
|
||||
|
||||
Reasonix 现在主要是单人维护,但是为协作设计的。给新手准备的入门 issue —— 每个都带背景说明、代码定位、验收标准、提示 —— 全部挂在 [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) 标签下。挑任意一个还没人认领的就行。
|
||||
给新手准备的入门 issue —— 每个都带背景说明、代码定位、验收标准、提示 —— 全部挂在 [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) 标签下。挑任意一个还没人认领的就行。
|
||||
|
||||
**正在征集意见的 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)拍砖
|
||||
- [#22 · 未来功能愿望单](https://github.com/esengine/reasonix/discussions/22) — 你希望 Reasonix 长出什么功能?
|
||||
|
||||
- [#20 · CLI / TUI 设计](https://github.com/esengine/reasonix/discussions/20) —— 哪里坏了、哪里少东西、哪里你会怎么改?
|
||||
- [#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)。项目没有营销预算,新用户主要通过社区口碑找到这里。持续参与传播的用户将获得下方这枚徽章,颁发后会展示在贡献者墙旁:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/reasonix/discussions/categories/show-and-tell">
|
||||
<img src="https://img.shields.io/badge/REASONIX-📣%20ADVOCATE-c4b5fd?style=for-the-badge&labelColor=0d1117" alt="Reasonix Advocate 徽章 —— 授予持续参与传播的用户"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**第一次提 PR 之前**:先读 [`CONTRIBUTING.md`](./CONTRIBUTING.md) —— 短小、严格的项目规则(注释、错误处理、用现成库不手写)。`tests/comment-policy.test.ts` 静态强制执行注释那部分,`npm run verify` 是 push 前的闸。参与本项目即同意 [行为准则](./CODE_OF_CONDUCT.md)。安全相关问题请走 [SECURITY.md](./SECURITY.md)。
|
||||
|
||||
### 贡献者
|
||||
<p align="center">
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/reasonix&max=100&columns=12" alt="esengine/reasonix 贡献者" width="860"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<a href="https://github.com/esengine/reasonix/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=esengine/reasonix" alt="esengine/reasonix 贡献者"/>
|
||||
</a>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 不做的事
|
||||
|
||||
- **多供应商灵活性。** 故意只做 DeepSeek —— 每一层都为 DeepSeek 特定的缓存机制和定价调过。绑死一个后端是 feature,不是要克服的限制。
|
||||
- **IDE 集成。** 终端优先;diff 在 `git diff`,文件树在 `ls`。仪表盘是 TUI 的伴生,不是 Cursor 的替代。
|
||||
- **追最难的 reasoning 榜单。** Claude Opus 在某些榜单上还是赢家。DeepSeek V4 在编程任务上有竞争力;如果你的工作是"解一个 PhD 级证明"而不是"修个 auth bug",先用 Claude。
|
||||
> [!IMPORTANT]
|
||||
> Reasonix 是有立场的。有些事它故意 *不做* —— 列在这里方便你为自己的工作挑对工具。
|
||||
|
||||
- **多供应商灵活性。** 故意只做 DeepSeek。绑死一个后端是 feature,不是限制。
|
||||
- **IDE 集成。** 终端优先。diff 在 `git diff`,文件树在 `ls`。仪表盘是 TUI 的伴生,不是 Cursor 的替代。
|
||||
- **追最难的 reasoning 榜单。** Claude Opus 在某些榜单上还是赢家。DeepSeek 在编程任务上有竞争力;如果你的工作是"解一个 PhD 级证明"而不是"修个 auth bug",先用 Claude。
|
||||
- **完全离线 / 永远免费。** Reasonix 需要付费的 DeepSeek API Key。要离线 / 零成本,看 Aider + Ollama 或 [Continue](https://continue.dev)。
|
||||
|
||||
<br/>
|
||||
|
||||
## Star 趋势
|
||||
|
||||
<a href="https://www.star-history.com/?repos=esengine%2Freasonix&type=timeline&logscale=&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&theme=dark&logscale&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&logscale&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/reasonix&type=timeline&logscale&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
## 协议
|
||||
|
||||
MIT —— 见 [LICENSE](./LICENSE)。
|
||||
<p align="center">
|
||||
<sub>MIT —— 见 <a href="./LICENSE">LICENSE</a></sub>
|
||||
<br/>
|
||||
<sub>由 <a href="https://github.com/esengine/reasonix/graphs/contributors">esengine/reasonix</a> 社区共建</sub>
|
||||
</p>
|
||||
|
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." },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Dashboard ↔ TUI parity
|
||||
|
||||
Closing audit of #369. Inventories every slash command and how the
|
||||
web dashboard handles it.
|
||||
|
||||
## Coverage legend
|
||||
|
||||
- **panel** — has a dedicated SPA panel or modal beyond just typing the
|
||||
slash in the chat box.
|
||||
- **chat-box** — works by typing the slash into the web chat input.
|
||||
Result text shows in the dashboard scrollback as an info row. No
|
||||
dedicated UI; the chat box _is_ the UI.
|
||||
- **tui-only** — keyboard binding or process-controlling action that
|
||||
has no useful web equivalent. Stays a TUI affordance by design.
|
||||
|
||||
## Counts
|
||||
|
||||
Roughly **20 commands have a dedicated panel**, **38 work via the chat
|
||||
box**, and **`/exit` plus 4 raw keyboard shortcuts** stay TUI-only.
|
||||
Aliases (e.g. `/sessions` and `/resume` share one picker) collapse
|
||||
into one row.
|
||||
|
||||
## Core / observability
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/help` | command reference | chat-box |
|
||||
| `/keys` | keyboard shortcuts + prompt prefixes | chat-box |
|
||||
| `/status` | model + flags + ctx + session | chat-box |
|
||||
| `/context` | context-window breakdown (stacked bar) | chat-box · `ctxBreakdown` payload |
|
||||
| `/cost` | last turn / next turn estimate | chat-box · usage card |
|
||||
| `/stats` | cross-session cost dashboard | **panel** · Usage tab |
|
||||
| `/think` | last R1 reasoning dump | chat-box |
|
||||
| `/tool [N]` | dump full output of Nth tool call | chat-box |
|
||||
| `/clear` | wipe visible scrollback | chat-box |
|
||||
| `/new` (`/reset`) | wipe context + scrollback | chat-box |
|
||||
| `/exit` (`/quit`, `/q`) | quit the TUI | **tui-only** |
|
||||
| `/stop` | abort current model turn | chat-box |
|
||||
| `/retry` | resend last user message | chat-box |
|
||||
| `/compact` | fold older turns into summary | chat-box |
|
||||
| `/update` | show current vs latest version | chat-box |
|
||||
| `/doctor` | health check card | chat-box · doctor card |
|
||||
|
||||
## Model & compute
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/preset` | model bundle (auto / flash / pro) | **panel** · Settings → Defaults |
|
||||
| `/effort` | reasoning cap (high / max) | **panel** · Settings → Defaults |
|
||||
| `/model` | active model | **panel** · Settings → Runtime (D-4 #437) |
|
||||
| `/models` | list available models | chat-box |
|
||||
| `/pro` | arm v4-pro for next turn | **panel** · Settings → Compute (D-2 #435) |
|
||||
| `/budget` | session USD cap | **panel** · Settings → Budget + cockpit tile (D-3 #436) |
|
||||
| `/branch` | parallel sample count | chat-box · *deletion-bound* |
|
||||
| `/harvest` | Pillar-2 plan-state extraction | chat-box · *deletion-bound* |
|
||||
| `/loop` | auto-resubmit on interval | **panel** · Settings → Loop (D-5 #438) |
|
||||
|
||||
## Memory & project
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/memory [list / show / forget / clear]` | manage pinned memory | **panel** · Memory tab |
|
||||
| `/init` | synthesize baseline REASONIX.md | chat-box |
|
||||
| `/semantic` | semantic-search index status | chat-box |
|
||||
| `/search-engine` (`/se`) | switch web search backend | chat-box |
|
||||
| `/language` (`/lang`) | runtime language | **panel** · Settings → Language |
|
||||
|
||||
## Sessions
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/sessions` | list saved sessions | **panel** · SessionPicker modal (C-2 #423) |
|
||||
| `/resume` | open a session | **panel** · same picker |
|
||||
| `/rename` | rename current session | chat-box |
|
||||
| `/forget` | delete current session | chat-box |
|
||||
| `/plans` | active + archived plans | **panel** · Plans tab |
|
||||
| `/replay [N]` | read-only plan archive | **panel** · Viewer modal (C-5 #427) |
|
||||
|
||||
## MCP
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/mcp` (list) | bridged servers + tools | **panel** · MCP tab |
|
||||
| `/mcp browse` | marketplace + install | **panel** · MCP marketplace picker (C-4 #426) |
|
||||
| `/mcp disable` / `enable` / `reconnect` / `text` | server admin | chat-box |
|
||||
| `/resource [uri]` | browse / read MCP resources | chat-box |
|
||||
| `/prompt [name]` | browse / fetch MCP prompts | chat-box |
|
||||
|
||||
## Permissions & admin
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/permissions [list / add / remove / clear]` | shell allowlist | **panel** · Permissions tab |
|
||||
| `/hooks [reload]` | active hooks | **panel** · Hooks tab |
|
||||
| `/dashboard [stop]` | embedded dashboard lifecycle | chat-box · *intentional — admin command for the surface you're typing in* |
|
||||
|
||||
## Code-mode only
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/init [force]` | scan + synthesize REASONIX.md | chat-box |
|
||||
| `/apply [N]` | commit pending edits | chat-box |
|
||||
| `/discard [N]` | drop pending edits | chat-box |
|
||||
| `/walk` | step through pending edits | **panel** · edit-review modal already covered web pre-#369 |
|
||||
| `/undo` | roll back last edit batch | chat-box |
|
||||
| `/history` | edit batch list | chat-box |
|
||||
| `/show [id]` | dump stored edit diff | chat-box |
|
||||
| `/commit "msg"` | git commit | chat-box |
|
||||
| `/checkpoint [name / list / forget]` | snapshot touched files | chat-box |
|
||||
| `/restore` | roll back to checkpoint | **panel** · CheckpointPicker modal (C-3 #425) |
|
||||
| `/plan [on / off]` | read-only plan mode | chat-box |
|
||||
| `/apply-plan` | force-approve pending plan | chat-box |
|
||||
| `/mode [review / auto / yolo]` | edit gate | **panel** · Chat header pill |
|
||||
| `/jobs` | list background jobs | chat-box |
|
||||
| `/kill <id>` | stop background job | chat-box |
|
||||
| `/logs <id> [lines]` | tail job output | chat-box |
|
||||
| `/skill [list / show / new / <name>]` | skill management | **panel** · Skills tab |
|
||||
|
||||
## Keyboard / TTY-native
|
||||
|
||||
These don't have slashes. They ride alongside the slash surface and
|
||||
stay TUI-only:
|
||||
|
||||
- `Esc` — abort current model turn (web equivalent: Abort button in chat)
|
||||
- `Shift+Tab` — cycle edit mode (web equivalent: mode pill in chat header)
|
||||
- `Ctrl-L`, `Ctrl-O`, `space`, `u` — TTY scroll / undo banner / pause hotkeys
|
||||
|
||||
## Done
|
||||
|
||||
Buckets covered:
|
||||
- **C** (#416, closed): pickers — `/sessions`, `/restore`, `/mcp browse`, `/replay` + `/walk` (already)
|
||||
- **D** (#428, closed): settings — `/preset`, `/effort`, `/model`, `/pro`, `/budget`, `/loop`, `/language`
|
||||
|
||||
Bucket A (text outputs that stay chat-box) and bucket B (structured
|
||||
outputs that already have panels) need no further work — the panels
|
||||
shipped in C/D plus the long-standing Memory / Permissions / Hooks /
|
||||
Skills / Plans / Usage tabs already cover every command whose output
|
||||
warranted a dedicated UI.
|
||||
|
||||
This file is the source of truth for the audit. Add a row when a
|
||||
new slash lands.
|
||||
@@ -690,8 +690,19 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
.crumbs .sep { color: var(--fg-4); }
|
||||
|
||||
/* ── Sessions panel ──────────────────────────────────────────────────── */
|
||||
.sessions-grid { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: 14px; min-height: 540px; }
|
||||
.sessions-list { background: var(--bg-elev); border: 1px solid var(--bd); border-radius: var(--r); display: flex; flex-direction: column; overflow: hidden; }
|
||||
.sessions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
/* `minmax(0, 1fr)` on the row + `min-height: 0` on the children is the
|
||||
standard recipe for "let the inner overflow:auto take effect" — without
|
||||
it the grid items default to min-height: auto (= content size) and
|
||||
grow past the parent's max-height, dragging .app-body along. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 540px;
|
||||
max-height: calc(100vh - 140px);
|
||||
}
|
||||
.sessions-list { background: var(--bg-elev); border: 1px solid var(--bd); border-radius: var(--r); display: flex; flex-direction: column; overflow: hidden; min-height: 0; min-width: 0; }
|
||||
.sessions-list .ssl-h { padding: 10px 12px; border-bottom: 1px solid var(--bd); display: flex; align-items: center; gap: 8px; }
|
||||
.sessions-list .ssl-h input {
|
||||
flex: 1; background: var(--bg-input); border: 1px solid var(--bd); border-radius: var(--r);
|
||||
@@ -710,7 +721,7 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
.ssl-row .meta { display: flex; gap: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-4); margin-top: 2px; }
|
||||
.ssl-row .meta .v { color: var(--fg-2); }
|
||||
|
||||
.sessions-detail { background: var(--bg-elev); border: 1px solid var(--bd); border-radius: var(--r); padding: 14px 16px; overflow: auto; }
|
||||
.sessions-detail { background: var(--bg-elev); border: 1px solid var(--bd); border-radius: var(--r); padding: 14px 16px; overflow: auto; min-height: 0; min-width: 0; }
|
||||
.sessions-detail-h { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--bd); }
|
||||
.sessions-detail-h .name { font-family: var(--font-mono); font-size: 14px; color: var(--fg-0); font-weight: 600; }
|
||||
.sessions-detail-h .ws { font-family: var(--font-mono); font-size: 11px; color: var(--fg-3); }
|
||||
@@ -801,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; }
|
||||
@@ -2005,6 +2019,155 @@ input:focus, textarea:focus, select:focus { border-color: var(--c-brand); }
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.modal-picker-search {
|
||||
width: 100%;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
color: var(--fg-1);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-picker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
background: var(--bg-elev-2);
|
||||
padding: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-picker-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4px 8px;
|
||||
align-items: baseline;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm, 4px);
|
||||
color: var(--fg-1);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-picker-row:hover {
|
||||
background: var(--bg-elev-3, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
.modal-picker-row.selected {
|
||||
border-color: var(--accent, #fcd34d);
|
||||
background: var(--bg-elev-3, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
.modal-picker-title {
|
||||
grid-column: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-picker-badge {
|
||||
grid-column: 2;
|
||||
font-size: 11px;
|
||||
color: var(--fg-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elev-1, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
.modal-picker-subtitle {
|
||||
grid-column: 1 / 3;
|
||||
font-size: 12px;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.modal-picker-meta {
|
||||
grid-column: 1 / 3;
|
||||
font-size: 11px;
|
||||
color: var(--fg-3, var(--fg-2));
|
||||
}
|
||||
|
||||
.modal-picker-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--fg-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-picker-more {
|
||||
align-self: center;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-picker-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-picker-form input {
|
||||
width: 100%;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
color: var(--fg-1);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-viewer-steps {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modal-viewer-step {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--r-sm, 4px);
|
||||
background: var(--bg-elev-2);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.modal-viewer-step-mark {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.modal-viewer-step-done .modal-viewer-step-mark {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.modal-viewer-step-title {
|
||||
color: var(--fg-1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-viewer-step-done .modal-viewer-step-title {
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.modal-viewer-body {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--fg-1);
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Side-by-side diff for the edit-review modal — left is "before" (red
|
||||
* tint), right is "after" (green tint), context rows render unchanged.
|
||||
* Lines hljs-highlight per the file's language. */
|
||||
|
||||
@@ -104,6 +104,49 @@ interface RevisionSpec {
|
||||
remainingSteps: RevisionStep[];
|
||||
}
|
||||
|
||||
export type PickerActionName =
|
||||
| "pick"
|
||||
| "delete"
|
||||
| "rename"
|
||||
| "new"
|
||||
| "install"
|
||||
| "uninstall"
|
||||
| "load-more"
|
||||
| "refine"
|
||||
| "cancel";
|
||||
|
||||
export interface PickerItemSpec {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: string;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface PickerModalSpec {
|
||||
pickerKind: string;
|
||||
title: string;
|
||||
query?: string;
|
||||
items: PickerItemSpec[];
|
||||
actions: PickerActionName[];
|
||||
hasMore?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ViewerStep {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "done" | "queued";
|
||||
}
|
||||
|
||||
export interface ViewerModalSpec {
|
||||
viewerKind: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
steps?: ViewerStep[];
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
interface DiffEntry {
|
||||
kind: "context" | "ins" | "del";
|
||||
text: string;
|
||||
@@ -617,6 +660,220 @@ export function CheckpointModal({ modal, onResolve }: { modal: CheckpointSpec; o
|
||||
`;
|
||||
}
|
||||
|
||||
export function PickerModal({
|
||||
modal,
|
||||
onResolve,
|
||||
}: {
|
||||
modal: PickerModalSpec;
|
||||
onResolve: OnResolve;
|
||||
}) {
|
||||
useLang();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(modal.items[0]?.id ?? null);
|
||||
const [query, setQuery] = useState(modal.query ?? "");
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [renameText, setRenameText] = useState("");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newText, setNewText] = useState("");
|
||||
|
||||
const has = (a: PickerActionName) => modal.actions.includes(a);
|
||||
const selected = modal.items.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
const submitRefine = (next: string) => {
|
||||
setQuery(next);
|
||||
if (has("refine")) onResolve("picker", { action: "refine", query: next });
|
||||
};
|
||||
|
||||
const startRename = (id: string) => {
|
||||
const item = modal.items.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
setRenameTarget(id);
|
||||
setRenameText(item.title);
|
||||
};
|
||||
|
||||
const sendRename = () => {
|
||||
if (!renameTarget || !renameText.trim()) return;
|
||||
onResolve("picker", { action: "rename", id: renameTarget, text: renameText });
|
||||
setRenameTarget(null);
|
||||
setRenameText("");
|
||||
};
|
||||
|
||||
const sendNew = () => {
|
||||
onResolve("picker", newText.trim() ? { action: "new", text: newText } : { action: "new" });
|
||||
setShowNew(false);
|
||||
setNewText("");
|
||||
};
|
||||
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#fcd34d"
|
||||
icon="≡"
|
||||
title=${modal.title}
|
||||
subtitle=${modal.hint}
|
||||
>
|
||||
${
|
||||
has("refine")
|
||||
? html`<input
|
||||
class="modal-picker-search"
|
||||
type="search"
|
||||
placeholder=${t("modal.pickerFilter")}
|
||||
value=${query}
|
||||
onInput=${(e: Event) => submitRefine((e.target as HTMLInputElement).value)}
|
||||
/>`
|
||||
: null
|
||||
}
|
||||
<div class="modal-picker-list">
|
||||
${
|
||||
modal.items.length === 0
|
||||
? html`<div class="modal-picker-empty">${t("modal.pickerEmpty")}</div>`
|
||||
: modal.items.map(
|
||||
(it) => html`
|
||||
<button
|
||||
key=${it.id}
|
||||
class=${`modal-picker-row${it.id === selectedId ? " selected" : ""}`}
|
||||
onClick=${() => setSelectedId(it.id)}
|
||||
onDblClick=${() => has("pick") && onResolve("picker", { action: "pick", id: it.id })}
|
||||
>
|
||||
<span class="modal-picker-title">${it.title}</span>
|
||||
${it.badge ? html`<span class="modal-picker-badge">${it.badge}</span>` : null}
|
||||
${it.subtitle ? html`<span class="modal-picker-subtitle">${it.subtitle}</span>` : null}
|
||||
${it.meta ? html`<span class="modal-picker-meta">${it.meta}</span>` : null}
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
${
|
||||
modal.hasMore && has("load-more")
|
||||
? html`<button
|
||||
class="modal-picker-more"
|
||||
onClick=${() => onResolve("picker", { action: "load-more" })}
|
||||
>${t("modal.pickerLoadMore")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
renameTarget
|
||||
? html`
|
||||
<div class="modal-picker-form">
|
||||
<input
|
||||
type="text"
|
||||
value=${renameText}
|
||||
onInput=${(e: Event) => setRenameText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${sendRename} disabled=${!renameText.trim()}>${t("common.save")}</button>
|
||||
<button onClick=${() => setRenameTarget(null)}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: showNew
|
||||
? html`
|
||||
<div class="modal-picker-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("modal.pickerNewPlaceholder")}
|
||||
value=${newText}
|
||||
onInput=${(e: Event) => setNewText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${sendNew}>${t("common.add")}</button>
|
||||
<button onClick=${() => setShowNew(false)}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="modal-actions">
|
||||
${
|
||||
has("pick") && selected
|
||||
? html`<button
|
||||
class="primary"
|
||||
onClick=${() => onResolve("picker", { action: "pick", id: selected.id })}
|
||||
>${t("modal.pickerPick")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("install") && selected
|
||||
? html`<button
|
||||
class="primary"
|
||||
onClick=${() => onResolve("picker", { action: "install", id: selected.id })}
|
||||
>${t("modal.pickerInstall")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("uninstall") && selected
|
||||
? html`<button
|
||||
onClick=${() => onResolve("picker", { action: "uninstall", id: selected.id })}
|
||||
>${t("modal.pickerUninstall")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("rename") && selected
|
||||
? html`<button onClick=${() => startRename(selected.id)}>${t("modal.pickerRename")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("delete") && selected
|
||||
? html`<button
|
||||
class="danger"
|
||||
onClick=${() => onResolve("picker", { action: "delete", id: selected.id })}
|
||||
>${t("common.delete")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("new")
|
||||
? html`<button onClick=${() => setShowNew(true)}>${t("modal.pickerNew")}</button>`
|
||||
: null
|
||||
}
|
||||
<button onClick=${() => onResolve("picker", { action: "cancel" })}>${t("modal.cancel")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function ViewerModal({
|
||||
modal,
|
||||
onResolve,
|
||||
}: {
|
||||
modal: ViewerModalSpec;
|
||||
onResolve: OnResolve;
|
||||
}) {
|
||||
useLang();
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#67e8f9"
|
||||
icon="◇"
|
||||
title=${modal.title}
|
||||
subtitle=${modal.meta}
|
||||
>
|
||||
${
|
||||
modal.steps && modal.steps.length > 0
|
||||
? html`
|
||||
<ol class="modal-viewer-steps">
|
||||
${modal.steps.map(
|
||||
(s: ViewerStep) => html`
|
||||
<li key=${s.id} class=${`modal-viewer-step modal-viewer-step-${s.status}`}>
|
||||
<span class="modal-viewer-step-mark">${s.status === "done" ? "✓" : "·"}</span>
|
||||
<span class="modal-viewer-step-title">${s.title}</span>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</ol>
|
||||
`
|
||||
: null
|
||||
}
|
||||
${
|
||||
modal.body
|
||||
? html`<div class="md modal-viewer-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body) }}></div>`
|
||||
: null
|
||||
}
|
||||
<div class="modal-actions">
|
||||
<button onClick=${() => onResolve("viewer", { action: "close" })}>${t("modal.viewerClose")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function RevisionModal({ modal, onResolve }: { modal: RevisionSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const riskColor = (r: string | undefined) =>
|
||||
|
||||
@@ -61,8 +61,36 @@ export const en = {
|
||||
effortHigh: "high (cheaper / faster)",
|
||||
webSearch: "web search",
|
||||
webSearchNote: "web_fetch + web_search tools",
|
||||
sectionCompute: "Compute",
|
||||
proNext: "/pro one-shot",
|
||||
proArm: "Arm for next turn",
|
||||
proArmed: "Armed — disarms after next turn",
|
||||
proNextNote: "next turn runs on deepseek-v4-pro, then auto-disarms",
|
||||
sectionBudget: "Budget",
|
||||
budgetOf: "of",
|
||||
budgetSetCap: "set a cap",
|
||||
budgetCustom: "custom",
|
||||
budgetBumpHint: "bump the cap to keep going",
|
||||
budgetClear: "Clear cap",
|
||||
budgetIdleLine: "warns at 80% · refuses past 100%",
|
||||
budgetWarnLine: "approaching cap — loop will refuse past 100%",
|
||||
budgetRefusing: "cap exhausted — next turn refused until bumped or cleared",
|
||||
sectionLoop: "Loop",
|
||||
loopIdleHint: "Auto-resubmit a prompt on a fixed interval.",
|
||||
loopCostHint: "Each iteration costs ~{cost} (last turn).",
|
||||
loopInterval: "interval",
|
||||
loopCustom: "custom",
|
||||
loopRangeError: "interval must fall in 5s..6h",
|
||||
loopPrompt: "prompt",
|
||||
loopPromptPlaceholder: "e.g. check the deploy status and report any errors",
|
||||
loopStart: "Start loop",
|
||||
loopStop: "Stop",
|
||||
loopRunning: "running",
|
||||
loopIter: "iter {iter}",
|
||||
loopFiresIn: "fires in {remaining}",
|
||||
sectionRuntime: "Runtime",
|
||||
activeModel: "active model",
|
||||
modelPricingLine: "${hit} hit · ${miss} miss · ${out} out per 1M tok",
|
||||
editMode: "edit mode",
|
||||
editModeNote: "switch from the Chat tab header",
|
||||
sectionLanguage: "Language",
|
||||
@@ -145,6 +173,7 @@ export const en = {
|
||||
tokens7d: "tokens · 7d",
|
||||
cacheHit: "cache hit",
|
||||
toolCalls24h: "tool calls · 24h",
|
||||
budget: "budget",
|
||||
currentSession: "current session",
|
||||
noSession: "No live session — /dashboard from inside reasonix code to attach.",
|
||||
promptTok: "prompt tok",
|
||||
@@ -264,6 +293,32 @@ export const en = {
|
||||
colName: "name",
|
||||
colDesc: "description",
|
||||
colUri: "uri",
|
||||
marketplace: "marketplace",
|
||||
marketplaceSearch: "search the registry…",
|
||||
marketplaceLoading: "loading registry…",
|
||||
marketplaceMore: "load 5 more pages",
|
||||
marketplaceMoreLabel: "load 50 more · showing {shown} / {total}",
|
||||
marketplaceMoreHint: "fetches more pages from the registry",
|
||||
marketplaceMoreCachedHint: "more entries already cached locally",
|
||||
marketplaceExhausted: "all pages loaded",
|
||||
marketplaceExhaustedFull: "showing all {total} entries — registry exhausted",
|
||||
marketplaceCount: "{loaded} loaded · {matched} match · source: {source}{cached}",
|
||||
marketplaceCachedSuffix: " · cached",
|
||||
marketplaceNoMatches: "No matches. Try different terms or load more pages.",
|
||||
marketplaceInstall: "Install",
|
||||
marketplacePickHint: "Pick a server on the left, then Install.",
|
||||
marketplaceInstalled: "installed → {spec}",
|
||||
marketplaceInstalledBridged: "installed + bridged → {spec}",
|
||||
marketplaceAlready: "already installed",
|
||||
marketplaceNeedsEnv: "needs env: {names}",
|
||||
marketplaceSourceTag: "[{source}]",
|
||||
marketplaceNoInstall: "smithery listing — install metadata not exposed; use `npx -y @smithery/cli install {name}` directly",
|
||||
marketplaceFetchOnInstall: "Smithery listing — install detail fetched on Install. http servers map to streamable-http remotes; stdio servers run via @smithery/cli.",
|
||||
marketplaceInstalledBadge: "installed",
|
||||
marketplaceUninstall: "Uninstall",
|
||||
marketplaceEnvTitle: "Required environment variables",
|
||||
marketplaceEnvHint: "Set these in your shell before next `reasonix code` so the bridged server can authenticate.",
|
||||
marketplaceRestartHint: "Spec written to ~/.reasonix/config.json. Restart `reasonix code` to bridge the server (live hot-reload is on the roadmap).",
|
||||
},
|
||||
memory: {
|
||||
loading: "loading memory…",
|
||||
@@ -349,6 +404,7 @@ export const en = {
|
||||
filterPlaceholder: "filter plans",
|
||||
active: "active",
|
||||
done: "done",
|
||||
idle: "idle",
|
||||
steps: "steps",
|
||||
pickHint: "Pick a plan on the left.",
|
||||
noTitle: "(no title)",
|
||||
@@ -481,5 +537,15 @@ export const en = {
|
||||
reject: "Reject",
|
||||
arguments: "arguments",
|
||||
revisePlaceholder: "What needs to change before the next step? Leave blank to just continue.",
|
||||
pickerFilter: "Filter…",
|
||||
pickerEmpty: "Nothing to show.",
|
||||
pickerLoadMore: "Load more",
|
||||
pickerPick: "Open",
|
||||
pickerInstall: "Install",
|
||||
pickerUninstall: "Uninstall",
|
||||
pickerRename: "Rename…",
|
||||
pickerNew: "New…",
|
||||
pickerNewPlaceholder: "Name (leave blank for default)",
|
||||
viewerClose: "Close",
|
||||
},
|
||||
};
|
||||
@@ -61,8 +61,36 @@ export const zhCN = {
|
||||
effortHigh: "high(更便宜 / 更快)",
|
||||
webSearch: "网页搜索",
|
||||
webSearchNote: "web_fetch + web_search 工具",
|
||||
sectionCompute: "计算",
|
||||
proNext: "/pro 单轮",
|
||||
proArm: "为下一轮装备",
|
||||
proArmed: "已装备 — 下一轮后自动解除",
|
||||
proNextNote: "下一轮使用 deepseek-v4-pro,之后自动解除",
|
||||
sectionBudget: "预算",
|
||||
budgetOf: "/",
|
||||
budgetSetCap: "设置上限",
|
||||
budgetCustom: "自定义",
|
||||
budgetBumpHint: "提高上限以继续",
|
||||
budgetClear: "清除上限",
|
||||
budgetIdleLine: "80% 时提醒 · 100% 后拒绝执行",
|
||||
budgetWarnLine: "接近上限 — 超过 100% 将拒绝执行",
|
||||
budgetRefusing: "已超出上限 — 提高或清除后才会继续",
|
||||
sectionLoop: "循环",
|
||||
loopIdleHint: "按固定间隔自动重新提交一段提示词。",
|
||||
loopCostHint: "每次迭代约 {cost}(上一轮成本)。",
|
||||
loopInterval: "间隔",
|
||||
loopCustom: "自定义",
|
||||
loopRangeError: "间隔需在 5s..6h 之间",
|
||||
loopPrompt: "提示词",
|
||||
loopPromptPlaceholder: "例如:检查部署状态并汇报任何错误",
|
||||
loopStart: "启动循环",
|
||||
loopStop: "停止",
|
||||
loopRunning: "运行中",
|
||||
loopIter: "第 {iter} 次",
|
||||
loopFiresIn: "{remaining} 后触发",
|
||||
sectionRuntime: "运行时",
|
||||
activeModel: "当前模型",
|
||||
modelPricingLine: "${hit} 命中 · ${miss} 未命中 · ${out} 输出 / 100 万 tok",
|
||||
editMode: "编辑模式",
|
||||
editModeNote: "在对话标签页头部切换",
|
||||
sectionLanguage: "语言",
|
||||
@@ -145,6 +173,7 @@ export const zhCN = {
|
||||
tokens7d: "tokens · 7 天",
|
||||
cacheHit: "缓存命中",
|
||||
toolCalls24h: "工具调用 · 24 小时",
|
||||
budget: "预算",
|
||||
currentSession: "当前会话",
|
||||
noSession: "无活跃会话 — 在 reasonix code 内执行 /dashboard 进行连接。",
|
||||
promptTok: "提示 tokens",
|
||||
@@ -264,6 +293,32 @@ export const zhCN = {
|
||||
colName: "名称",
|
||||
colDesc: "描述",
|
||||
colUri: "URI",
|
||||
marketplace: "市场",
|
||||
marketplaceSearch: "搜索注册表…",
|
||||
marketplaceLoading: "加载注册表…",
|
||||
marketplaceMore: "再加载 5 页",
|
||||
marketplaceMoreLabel: "再加载 50 条 · 当前 {shown} / {total}",
|
||||
marketplaceMoreHint: "需要从远端注册表再拉几页",
|
||||
marketplaceMoreCachedHint: "本地缓存已有更多条目",
|
||||
marketplaceExhausted: "已加载全部页",
|
||||
marketplaceExhaustedFull: "已展示全部 {total} 条 — 注册表耗尽",
|
||||
marketplaceCount: "已载入 {loaded} · 匹配 {matched} · 来源:{source}{cached}",
|
||||
marketplaceCachedSuffix: " · 缓存中",
|
||||
marketplaceNoMatches: "无匹配结果。换关键词或加载更多页。",
|
||||
marketplaceInstall: "安装",
|
||||
marketplacePickHint: "在左侧选择服务器,然后点安装。",
|
||||
marketplaceInstalled: "已安装 → {spec}",
|
||||
marketplaceInstalledBridged: "已安装并桥接 → {spec}",
|
||||
marketplaceAlready: "已安装过",
|
||||
marketplaceNeedsEnv: "需设置环境变量:{names}",
|
||||
marketplaceSourceTag: "[{source}]",
|
||||
marketplaceNoInstall: "Smithery 列表项 — 不暴露安装元数据;请直接 `npx -y @smithery/cli install {name}`",
|
||||
marketplaceFetchOnInstall: "Smithery 列表 — 安装时再拉详情。HTTP 服务映射为 streamable-http 远端;stdio 服务通过 @smithery/cli 运行。",
|
||||
marketplaceInstalledBadge: "已安装",
|
||||
marketplaceUninstall: "卸载",
|
||||
marketplaceEnvTitle: "必需的环境变量",
|
||||
marketplaceEnvHint: "下次启动 `reasonix code` 之前在 shell 里设好,桥接的服务器才能正常鉴权。",
|
||||
marketplaceRestartHint: "已写入 ~/.reasonix/config.json。重启 `reasonix code` 后服务器才会真正桥接(热重载在路线图上)。",
|
||||
},
|
||||
memory: {
|
||||
loading: "加载记忆…",
|
||||
@@ -349,6 +404,7 @@ export const zhCN = {
|
||||
filterPlaceholder: "筛选计划",
|
||||
active: "进行中",
|
||||
done: "已完成",
|
||||
idle: "未开始",
|
||||
steps: "步骤",
|
||||
pickHint: "选择左侧的计划。",
|
||||
noTitle: "(无标题)",
|
||||
@@ -481,5 +537,15 @@ export const zhCN = {
|
||||
reject: "拒绝",
|
||||
arguments: "参数",
|
||||
revisePlaceholder: "下一步之前需要更改什么?留空则直接继续。",
|
||||
pickerFilter: "过滤…",
|
||||
pickerEmpty: "暂无内容。",
|
||||
pickerLoadMore: "加载更多",
|
||||
pickerPick: "打开",
|
||||
pickerInstall: "安装",
|
||||
pickerUninstall: "卸载",
|
||||
pickerRename: "重命名…",
|
||||
pickerNew: "新建…",
|
||||
pickerNewPlaceholder: "名称(留空使用默认)",
|
||||
viewerClose: "关闭",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
export type BudgetState =
|
||||
| { kind: "off"; spent: number }
|
||||
| { kind: "running"; cap: number; spent: number; pct: number }
|
||||
| { kind: "warn"; cap: number; spent: number; pct: number }
|
||||
| { kind: "exhausted"; cap: number; spent: number; pct: number };
|
||||
|
||||
export function deriveBudgetState(
|
||||
cap: number | null | undefined,
|
||||
spent: number | null | undefined,
|
||||
): BudgetState {
|
||||
const safeSpent = typeof spent === "number" && spent >= 0 ? spent : 0;
|
||||
if (typeof cap !== "number" || cap <= 0) {
|
||||
return { kind: "off", spent: safeSpent };
|
||||
}
|
||||
const pct = (safeSpent / cap) * 100;
|
||||
if (pct >= 100) return { kind: "exhausted", cap, spent: safeSpent, pct };
|
||||
if (pct >= 80) return { kind: "warn", cap, spent: safeSpent, pct };
|
||||
return { kind: "running", cap, spent: safeSpent, pct };
|
||||
}
|
||||
|
||||
/** Default quick-cap menu — round dollar amounts users actually pick. */
|
||||
export const QUICK_CAPS_USD: ReadonlyArray<number> = [1, 5, 10, 25, 50];
|
||||
|
||||
/** 1.5× / 2× / 4× the current cap, snapped to a "nice" round number per bucket. */
|
||||
export function bumpSuggestions(currentCap: number): number[] {
|
||||
if (currentCap <= 0) return [];
|
||||
return [niceUp(currentCap * 1.5), niceUp(currentCap * 2), niceUp(currentCap * 4)];
|
||||
}
|
||||
|
||||
function niceUp(n: number): number {
|
||||
// Subtract a tiny epsilon before ceil so FP noise (0.4 * 1.5 = 0.6000…01)
|
||||
// doesn't bump a value to the next bucket.
|
||||
const eps = 1e-9;
|
||||
if (n < 1) return Math.ceil((n - eps) * 10) / 10;
|
||||
if (n < 10) return Math.ceil((n - eps) * 2) / 2;
|
||||
if (n < 100) return Math.ceil(n - eps);
|
||||
return Math.ceil((n - eps) / 5) * 5;
|
||||
}
|
||||
|
||||
/** Tone class shared between the cockpit tile and the settings gauge. */
|
||||
export function budgetTone(state: BudgetState): "" | "warn" | "err" {
|
||||
if (state.kind === "exhausted") return "err";
|
||||
if (state.kind === "warn") return "warn";
|
||||
return "";
|
||||
}
|
||||
@@ -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)}%`;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export type IntervalUnit = "s" | "m" | "h";
|
||||
|
||||
export interface LoopRunStatus {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
/** Wall-clock ms until the next fire — server reports a remaining duration, not an absolute. */
|
||||
nextFireMs: number;
|
||||
}
|
||||
|
||||
/** Quick-pick intervals in ms — covers the 95% of cases users actually run. */
|
||||
export const INTERVAL_PRESETS_MS: ReadonlyArray<{ ms: number; label: string }> = [
|
||||
{ ms: 30_000, label: "30s" },
|
||||
{ ms: 60_000, label: "1m" },
|
||||
{ ms: 5 * 60_000, label: "5m" },
|
||||
{ ms: 15 * 60_000, label: "15m" },
|
||||
{ ms: 60 * 60_000, label: "1h" },
|
||||
{ ms: 6 * 60 * 60_000, label: "6h" },
|
||||
];
|
||||
|
||||
const UNIT_TO_MS: Record<IntervalUnit, number> = {
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 60 * 60_000,
|
||||
};
|
||||
|
||||
const MIN_INTERVAL_MS = 5_000;
|
||||
const MAX_INTERVAL_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** Convert a "30" + "s" pair to ms, returning null if out of [5s, 6h]. */
|
||||
export function parseCustomInterval(value: string, unit: IntervalUnit): number | null {
|
||||
const n = Number.parseFloat(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const ms = Math.round(n * UNIT_TO_MS[unit]);
|
||||
if (ms < MIN_INTERVAL_MS || ms > MAX_INTERVAL_MS) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
/** Human-friendly "5m 12s" / "12s" / "2h 45m" — shows two largest non-zero units. */
|
||||
export function formatRemaining(ms: number): string {
|
||||
const safe = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(safe / 3600);
|
||||
const m = Math.floor((safe % 3600) / 60);
|
||||
const s = safe % 60;
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
@@ -6,15 +6,17 @@ import {
|
||||
ChoiceModal,
|
||||
EditReviewModal,
|
||||
type OnResolve,
|
||||
PickerModal,
|
||||
PlanModal,
|
||||
RevisionModal,
|
||||
ShellModal,
|
||||
ViewerModal,
|
||||
WorkspaceModal,
|
||||
parseToolArgs,
|
||||
} 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 +561,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">
|
||||
${
|
||||
@@ -684,7 +686,11 @@ export function ChatPanel() {
|
||||
? html`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "revision"
|
||||
? html`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: null
|
||||
: modal.kind === "picker"
|
||||
? html`<${PickerModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "viewer"
|
||||
? html`<${ViewerModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: null
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -791,6 +797,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 +808,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 +826,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 +996,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,8 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtNum } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface McpServer {
|
||||
label: string;
|
||||
@@ -20,6 +20,60 @@ interface McpData {
|
||||
servers: McpServer[];
|
||||
}
|
||||
|
||||
interface RegistryInstall {
|
||||
runtime: string;
|
||||
packageId?: string;
|
||||
version?: string;
|
||||
transport: string;
|
||||
url?: string;
|
||||
requiredEnv?: string[];
|
||||
extraArgs?: string[];
|
||||
}
|
||||
|
||||
interface RegistryEntryDto {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
source: "official" | "smithery" | "local";
|
||||
install?: RegistryInstall;
|
||||
popularity?: number;
|
||||
homepage?: string;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
/** Mirror of src/mcp/registry-fetch.ts:specStringFor — kept in sync to detect already-installed state without an extra round-trip. */
|
||||
function specForEntry(e: RegistryEntryDto): string | null {
|
||||
if (!e.install) return null;
|
||||
const localName = e.name.split("/").pop() ?? e.name;
|
||||
const safe = localName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/^-+|-+$/g, "") || "mcp";
|
||||
const trail = e.install.extraArgs?.length ? ` ${e.install.extraArgs.join(" ")}` : "";
|
||||
if (e.install.runtime === "remote" && e.install.url) {
|
||||
if (e.install.transport === "streamable-http") return `${safe}=streamable+${e.install.url}`;
|
||||
return `${safe}=${e.install.url}`;
|
||||
}
|
||||
if (e.install.runtime === "npm" && e.install.packageId) {
|
||||
const pinned = e.install.version
|
||||
? `${e.install.packageId}@${e.install.version}`
|
||||
: e.install.packageId;
|
||||
return `${safe}=npx -y ${pinned}${trail}`;
|
||||
}
|
||||
if (e.install.runtime === "pypi" && e.install.packageId) {
|
||||
return `${safe}=uvx ${e.install.packageId}${trail}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface RegistryListResponse {
|
||||
source: "official" | "smithery" | "local";
|
||||
fromCache: boolean;
|
||||
fetchedAt: number;
|
||||
loaded: number;
|
||||
hasMore: boolean;
|
||||
matched: number;
|
||||
entries: RegistryEntryDto[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function specLabel(spec: string): string {
|
||||
const eq = spec.indexOf("=");
|
||||
return eq > 0 ? spec.slice(0, eq) : spec;
|
||||
@@ -30,7 +84,7 @@ function specCommand(spec: string): string {
|
||||
return eq > 0 ? spec.slice(eq + 1) : spec;
|
||||
}
|
||||
|
||||
type McpFilter = "all" | "live" | "unbridged";
|
||||
type McpFilter = "all" | "live" | "unbridged" | "marketplace";
|
||||
|
||||
export function McpPanel() {
|
||||
useLang();
|
||||
@@ -43,6 +97,70 @@ export function McpPanel() {
|
||||
const [open, setOpen] = useState<McpServer | null>(null);
|
||||
const [openUnbridged, setOpenUnbridged] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<McpFilter>("all");
|
||||
const [registry, setRegistry] = useState<RegistryListResponse | null>(null);
|
||||
const [registryQuery, setRegistryQuery] = useState("");
|
||||
const [registryLoading, setRegistryLoading] = useState(false);
|
||||
const [openRegistry, setOpenRegistry] = useState<RegistryEntryDto | null>(null);
|
||||
/** Display cap — grows by 50 each "load more" click. Server caps response size at this. */
|
||||
const [displayLimit, setDisplayLimit] = useState(50);
|
||||
|
||||
const loadRegistry = useCallback(async (q: string, pages: number, limit: number) => {
|
||||
setRegistryLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
params.set("pages", String(pages));
|
||||
params.set("maxPages", String(Math.max(20, pages)));
|
||||
params.set("limit", String(limit));
|
||||
const r = await api<RegistryListResponse>(`/mcp/registry?${params.toString()}`);
|
||||
setRegistry(r);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setRegistryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== "marketplace") return;
|
||||
if (registry) return;
|
||||
void loadRegistry("", 1, displayLimit);
|
||||
}, [filter, registry, loadRegistry, displayLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== "marketplace") return;
|
||||
// Reset the display cap whenever the user retypes; new query = fresh top-50.
|
||||
setDisplayLimit(50);
|
||||
const handle = setTimeout(() => void loadRegistry(registryQuery, 1, 50), 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [registryQuery, filter, loadRegistry]);
|
||||
|
||||
const installFromRegistry = useCallback(async (entry: RegistryEntryDto) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api<{
|
||||
added: boolean;
|
||||
alreadyPresent?: boolean;
|
||||
bridged?: boolean;
|
||||
spec: string;
|
||||
}>("/mcp/registry/install", { method: "POST", body: { name: entry.name } });
|
||||
if (r.alreadyPresent) {
|
||||
setInfo(t("mcp.marketplaceAlready"));
|
||||
} else if (r.bridged) {
|
||||
setInfo(t("mcp.marketplaceInstalledBridged", { spec: r.spec }));
|
||||
} else {
|
||||
setInfo(t("mcp.marketplaceInstalled", { spec: r.spec }));
|
||||
}
|
||||
setTimeout(() => setInfo(null), 5000);
|
||||
// Reload BOTH live + spec lists since hot-reload should have
|
||||
// attached the new bridge.
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -64,9 +182,7 @@ export function McpPanel() {
|
||||
method: "POST",
|
||||
body: { spec: newSpec.trim() },
|
||||
});
|
||||
setInfo(
|
||||
r.requiresRestart ? t("mcp.savedRestart") : t("mcp.saved"),
|
||||
);
|
||||
setInfo(r.requiresRestart ? t("mcp.savedRestart") : t("mcp.saved"));
|
||||
setTimeout(() => setInfo(null), 4000);
|
||||
setNewSpec("");
|
||||
await load();
|
||||
@@ -95,15 +211,17 @@ export function McpPanel() {
|
||||
[load],
|
||||
);
|
||||
|
||||
if (!data && !error) return html`<div class="card" style="color:var(--fg-3)">${t("mcp.loading")}</div>`;
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("mcp.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
|
||||
const liveCount = data.servers.length;
|
||||
const unbridgedSpecs = (specs ?? []).filter((spec) => !data.servers.some((s) => s.spec === spec));
|
||||
const unbridgedCount = unbridgedSpecs.length;
|
||||
const showLive = filter !== "unbridged";
|
||||
const showUnbridged = filter !== "live";
|
||||
const showLive = filter === "all" || filter === "live";
|
||||
const showUnbridged = filter === "all" || filter === "unbridged";
|
||||
const showMarketplace = filter === "marketplace";
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
@@ -116,29 +234,82 @@ export function McpPanel() {
|
||||
<span class=${`chip-f ${filter === "all" ? "active" : ""}`} onClick=${() => setFilter("all")}>${t("mcp.all")} <span class="ct">${liveCount + unbridgedCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "live" ? "active" : ""}`} onClick=${() => setFilter("live")}>${t("mcp.live")} <span class="ct">${liveCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "unbridged" ? "active" : ""}`} onClick=${() => setFilter("unbridged")}>${t("mcp.unbridged")} <span class="ct">${unbridgedCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "marketplace" ? "active" : ""}`} onClick=${() => setFilter("marketplace")}>${t("mcp.marketplace")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 12px;display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("mcp.specPlaceholder")}
|
||||
value=${newSpec}
|
||||
onInput=${(e: Event) => setNewSpec((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;font-size:11px"
|
||||
/>
|
||||
<button class="btn primary" disabled=${busy || !newSpec.trim()} onClick=${addSpec}>+</button>
|
||||
</div>
|
||||
${
|
||||
showMarketplace
|
||||
? html`
|
||||
<div style="padding:8px 12px;display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("mcp.marketplaceSearch")}
|
||||
value=${registryQuery}
|
||||
onInput=${(e: Event) => setRegistryQuery((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;font-size:11px"
|
||||
/>
|
||||
</div>
|
||||
${
|
||||
registry
|
||||
? html`<div style="padding:0 12px 6px;font-size:11px;color:var(--fg-3)">
|
||||
${t("mcp.marketplaceCount", {
|
||||
loaded: registry.loaded,
|
||||
matched: registry.matched,
|
||||
source: registry.source,
|
||||
cached: registry.fromCache ? t("mcp.marketplaceCachedSuffix") : "",
|
||||
})}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
`
|
||||
: html`
|
||||
<div style="padding:8px 12px;display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("mcp.specPlaceholder")}
|
||||
value=${newSpec}
|
||||
onInput=${(e: Event) => setNewSpec((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;font-size:11px"
|
||||
/>
|
||||
<button class="btn primary" disabled=${busy || !newSpec.trim()} onClick=${addSpec}>+</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
${info ? html`<div style="padding:0 12px 8px"><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err" style="margin:0 12px 8px">${error}</div>` : null}
|
||||
|
||||
<div class="ssl-rows">
|
||||
${
|
||||
liveCount === 0 && unbridgedCount === 0
|
||||
!showMarketplace && liveCount === 0 && unbridgedCount === 0
|
||||
? html`<div style="color:var(--fg-3);padding:14px;font-size:12px">
|
||||
${t("mcp.noServers")}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
showMarketplace
|
||||
? renderMarketplaceRows({
|
||||
registry,
|
||||
registryLoading,
|
||||
openRegistry,
|
||||
setOpenRegistry: (e) => {
|
||||
setOpenRegistry(e);
|
||||
setOpen(null);
|
||||
setOpenUnbridged(null);
|
||||
},
|
||||
loadMore: () => {
|
||||
const nextLimit = displayLimit + 50;
|
||||
setDisplayLimit(nextLimit);
|
||||
// Pages: walk far enough to fill the new cap (each page ≈ 30
|
||||
// entries) plus a few-page lookahead so the next click also
|
||||
// has fresh data.
|
||||
const pagesNeeded = Math.ceil(nextLimit / 30) + 3;
|
||||
void loadRegistry(registryQuery, pagesNeeded, nextLimit);
|
||||
},
|
||||
installedSpecs: new Set(specs ?? []),
|
||||
})
|
||||
: null
|
||||
}
|
||||
${
|
||||
showLive
|
||||
? data.servers.map(
|
||||
@@ -182,8 +353,20 @@ export function McpPanel() {
|
||||
|
||||
<div class="sessions-detail">
|
||||
${
|
||||
openUnbridged != null
|
||||
? html`
|
||||
openRegistry != null
|
||||
? renderRegistryDetail({
|
||||
entry: openRegistry,
|
||||
busy,
|
||||
installedSpec: (() => {
|
||||
const spec = specForEntry(openRegistry);
|
||||
return spec && (specs ?? []).includes(spec) ? spec : null;
|
||||
})(),
|
||||
onInstall: () => installFromRegistry(openRegistry),
|
||||
onUninstall: (spec: string) => removeSpec(spec),
|
||||
onClose: () => setOpenRegistry(null),
|
||||
})
|
||||
: openUnbridged != null
|
||||
? html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${specLabel(openUnbridged)}</span>
|
||||
<span class="ws"><span class="pill">${t("mcp.unbridgedTitle")}</span></span>
|
||||
@@ -207,11 +390,11 @@ export function McpPanel() {
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${t("mcp.pickHint")}
|
||||
: open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${showMarketplace ? t("mcp.marketplacePickHint") : t("mcp.pickHint")}
|
||||
</div>`
|
||||
: html`
|
||||
: html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.label}</span>
|
||||
<span class="ws">${open.serverInfo?.name ?? "—"} ${open.serverInfo?.version ? `v${open.serverInfo.version}` : ""} · ${open.protocolVersion ?? "—"}</span>
|
||||
@@ -295,3 +478,198 @@ export function McpPanel() {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface MarketplaceRowsArgs {
|
||||
registry: RegistryListResponse | null;
|
||||
registryLoading: boolean;
|
||||
openRegistry: RegistryEntryDto | null;
|
||||
setOpenRegistry: (entry: RegistryEntryDto) => void;
|
||||
loadMore: () => void;
|
||||
installedSpecs: Set<string>;
|
||||
}
|
||||
|
||||
function renderLoadMoreFooter({
|
||||
registry,
|
||||
registryLoading,
|
||||
loadMore,
|
||||
}: Pick<MarketplaceRowsArgs, "registry" | "registryLoading" | "loadMore">) {
|
||||
if (!registry) return null;
|
||||
const shown = registry.entries.length;
|
||||
const total = registry.matched;
|
||||
const moreCached = total > shown;
|
||||
const moreOnNetwork = registry.hasMore;
|
||||
const canDoSomething = moreCached || moreOnNetwork;
|
||||
|
||||
// Three states:
|
||||
// 1. Loading — disabled button + spinner-ish label
|
||||
// 2. More available — primary button + count of what's loaded
|
||||
// 3. Exhausted — distinct success-tinted card so the user
|
||||
// doesn't think the button stopped responding
|
||||
if (canDoSomething) {
|
||||
const label = registryLoading
|
||||
? t("mcp.marketplaceLoading")
|
||||
: t("mcp.marketplaceMoreLabel", {
|
||||
shown,
|
||||
total: moreOnNetwork ? `${total}+` : `${total}`,
|
||||
});
|
||||
return html`<div style="padding:10px 12px;display:flex;align-items:center;gap:10px">
|
||||
<button class="btn primary" disabled=${registryLoading} onClick=${loadMore}>${label}</button>
|
||||
<span style="font-size:11px;color:var(--fg-3)">
|
||||
${moreOnNetwork ? t("mcp.marketplaceMoreHint") : t("mcp.marketplaceMoreCachedHint")}
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<div style="padding:12px;background:var(--bg-elev-2,rgba(36,143,242,0.07));border-top:1px solid var(--bd);display:flex;align-items:center;gap:8px;font-size:12px;color:var(--fg-2)">
|
||||
<span style="color:var(--c-ok)">✓</span>
|
||||
<span>${t("mcp.marketplaceExhaustedFull", { total })}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMarketplaceRows({
|
||||
registry,
|
||||
registryLoading,
|
||||
openRegistry,
|
||||
setOpenRegistry,
|
||||
loadMore,
|
||||
installedSpecs,
|
||||
}: MarketplaceRowsArgs) {
|
||||
if (!registry && registryLoading) {
|
||||
return html`<div style="color:var(--fg-3);padding:14px;font-size:12px">${t("mcp.marketplaceLoading")}</div>`;
|
||||
}
|
||||
if (!registry || registry.entries.length === 0) {
|
||||
return html`<div style="color:var(--fg-3);padding:14px;font-size:12px">${t("mcp.marketplaceNoMatches")}</div>`;
|
||||
}
|
||||
return html`
|
||||
${registry.entries.map((e) => {
|
||||
const sel = openRegistry?.name === e.name;
|
||||
const tag = t("mcp.marketplaceSourceTag", { source: e.source });
|
||||
const spec = specForEntry(e);
|
||||
const installed = spec !== null && installedSpecs.has(spec);
|
||||
const pop =
|
||||
e.popularity !== undefined
|
||||
? html` <span class="dim">· ${fmtNum(e.popularity)}</span>`
|
||||
: null;
|
||||
const icon = e.iconUrl
|
||||
? html`<img src=${e.iconUrl} alt="" style="width:16px;height:16px;border-radius:3px;margin-right:6px;vertical-align:middle;object-fit:cover" loading="lazy" referrerpolicy="no-referrer" onError=${(ev: Event) => ((ev.target as HTMLImageElement).style.display = "none")} />`
|
||||
: null;
|
||||
return html`
|
||||
<div class=${`ssl-row ${sel ? "sel" : ""}`} onClick=${() => setOpenRegistry(e)}>
|
||||
<span class="name">${icon}${e.name} <span class="pill">${tag}</span>${installed ? html` <span class="pill ok">${t("mcp.marketplaceInstalledBadge")}</span>` : null}</span>
|
||||
<span class="preview">${e.description}</span>
|
||||
<span class="meta">${pop}</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${renderLoadMoreFooter({ registry, registryLoading, loadMore })}
|
||||
`;
|
||||
}
|
||||
|
||||
interface RegistryDetailArgs {
|
||||
entry: RegistryEntryDto;
|
||||
busy: boolean;
|
||||
installedSpec: string | null;
|
||||
onInstall: () => void;
|
||||
onUninstall: (spec: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function renderRegistryDetail({
|
||||
entry,
|
||||
busy,
|
||||
installedSpec,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onClose,
|
||||
}: RegistryDetailArgs) {
|
||||
const installable = !!entry.install || entry.source === "smithery";
|
||||
const installed = installedSpec !== null;
|
||||
const specPreview = entry.install
|
||||
? `${entry.install.runtime} · ${entry.install.transport}${
|
||||
entry.install.packageId
|
||||
? ` · ${entry.install.packageId}`
|
||||
: entry.install.url
|
||||
? ` · ${entry.install.url}`
|
||||
: ""
|
||||
}${entry.install.version ? `@${entry.install.version}` : ""}`
|
||||
: "";
|
||||
const icon = entry.iconUrl
|
||||
? html`<img src=${entry.iconUrl} alt="" style="width:24px;height:24px;border-radius:4px;margin-right:8px;vertical-align:middle;object-fit:cover" loading="lazy" referrerpolicy="no-referrer" onError=${(ev: Event) => ((ev.target as HTMLImageElement).style.display = "none")} />`
|
||||
: null;
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${icon}${entry.name}${installed ? html` <span class="pill ok">${t("mcp.marketplaceInstalledBadge")}</span>` : null}</span>
|
||||
<span class="ws">${t("mcp.marketplaceSourceTag", { source: entry.source })}${
|
||||
entry.popularity !== undefined ? ` · ${fmtNum(entry.popularity)} uses` : ""
|
||||
}${entry.homepage ? html` · <a href=${entry.homepage} target="_blank" rel="noopener noreferrer">homepage</a>` : ""}</span>
|
||||
<span class="actions">
|
||||
${
|
||||
installed
|
||||
? html`<button
|
||||
class="btn"
|
||||
disabled=${busy}
|
||||
onClick=${() => onUninstall(installedSpec)}
|
||||
style="border-color:var(--c-err);color:var(--c-err)"
|
||||
>${t("mcp.marketplaceUninstall")}</button>`
|
||||
: html`<button class="btn primary" disabled=${busy || !installable} onClick=${onInstall}>${t("mcp.marketplaceInstall")}</button>`
|
||||
}
|
||||
<button class="btn ghost" onClick=${onClose}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6">${entry.description || "—"}</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
entry.install
|
||||
? html`<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">${t("mcp.spec")}</span></div>
|
||||
<div class="card-b">
|
||||
<code class="mono" style="font-size:11.5px;color:var(--fg-2);word-break:break-all;display:block">${specPreview}</code>
|
||||
${
|
||||
installedSpec
|
||||
? html`<div style="margin-top:8px;font-size:11px;color:var(--fg-3)">
|
||||
<span class="dim">on disk:</span> <code class="mono">${installedSpec}</code>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</div>`
|
||||
: entry.source === "smithery"
|
||||
? html`<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6;color:var(--fg-3)">
|
||||
${t("mcp.marketplaceFetchOnInstall")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
entry.install?.requiredEnv?.length
|
||||
? html`<div class="card accent-brand" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">${t("mcp.marketplaceEnvTitle")}</span></div>
|
||||
<div class="card-b" style="font-size:13px">
|
||||
${entry.install.requiredEnv.map(
|
||||
(name) =>
|
||||
html`<div><code class="mono" style="color:var(--c-warn)">${name}</code></div>`,
|
||||
)}
|
||||
<div style="margin-top:6px;color:var(--fg-3);font-size:12px">
|
||||
${t("mcp.marketplaceEnvHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
installed
|
||||
? html`<div class="card accent-warn">
|
||||
<div class="card-b" style="font-size:12.5px;line-height:1.6">
|
||||
${t("mcp.marketplaceRestartHint")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fmtCompactNum, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
|
||||
import { budgetTone, deriveBudgetState } from "../lib/budget.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";
|
||||
@@ -65,6 +66,9 @@ interface OverviewData {
|
||||
toolCount?: number;
|
||||
cwd?: string;
|
||||
cockpit?: CockpitData;
|
||||
budgetUsd?: number | null;
|
||||
/** Cumulative session spend in USD — set when a session is attached. */
|
||||
sessionSpendUsd?: number | null;
|
||||
}
|
||||
|
||||
function kpi(label: string, value: unknown, delta?: unknown, deltaTone?: "up" | "down" | "flat") {
|
||||
@@ -107,6 +111,25 @@ function balanceKpi(c: CockpitData) {
|
||||
return kpi(t("overview.balance"), `${symbol}${c.balance.total}`, c.balance.currency, "flat");
|
||||
}
|
||||
|
||||
function budgetKpi(o: OverviewData) {
|
||||
const state = deriveBudgetState(o.budgetUsd, o.cockpit?.currentSession?.totalCostUsd ?? null);
|
||||
if (state.kind === "off") return null;
|
||||
const tone = budgetTone(state);
|
||||
const valueColor =
|
||||
tone === "err"
|
||||
? "color:var(--c-err)"
|
||||
: tone === "warn"
|
||||
? "color:var(--c-warn)"
|
||||
: "";
|
||||
return html`
|
||||
<div class="kpi cock-w-1">
|
||||
<div class="label">${t("overview.budget")}</div>
|
||||
<div class="value" style=${valueColor}>${fmtUsd(state.spent)} / ${fmtUsd(state.cap)}</div>
|
||||
<div class=${`progress ${tone}`} style="margin-top:4px"><div class="progress-fill" style=${`width:${Math.min(100, state.pct)}%`}></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function tokens7dKpi(c: CockpitData) {
|
||||
if (!c.tokens7d) return kpi(t("overview.tokens7d"), "—", t("overview.noUsageYet"), "flat");
|
||||
const d = deltaPctText(c.tokens7d.deltaPct);
|
||||
@@ -144,6 +167,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 +177,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 +209,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} />
|
||||
@@ -284,6 +308,7 @@ export function OverviewPanel() {
|
||||
${tokens7dKpi(c)}
|
||||
${cacheHitKpi(c)}
|
||||
${toolCallsKpi(c)}
|
||||
${budgetKpi(o)}
|
||||
|
||||
${currentSessionBlock(c)}
|
||||
${costTrendSpark(c)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
${
|
||||
|
||||
@@ -29,7 +29,7 @@ interface PlansData {
|
||||
function statusPill(p: ArchivedPlan) {
|
||||
if (p.completionRatio >= 1) return html`<span class="pill ok">${t("plans.done")}</span>`;
|
||||
if (p.completionRatio > 0) return html`<span class="pill info">${t("plans.active")}</span>`;
|
||||
return html`<span class="pill">idle</span>`;
|
||||
return html`<span class="pill">${t("plans.idle")}</span>`;
|
||||
}
|
||||
|
||||
export function PlansPanel() {
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import {
|
||||
type BudgetState,
|
||||
QUICK_CAPS_USD,
|
||||
budgetTone,
|
||||
bumpSuggestions,
|
||||
deriveBudgetState,
|
||||
} from "../lib/budget.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import {
|
||||
INTERVAL_PRESETS_MS,
|
||||
type IntervalUnit,
|
||||
type LoopRunStatus,
|
||||
formatRemaining,
|
||||
parseCustomInterval,
|
||||
} from "../lib/loop-control.js";
|
||||
import { type DashboardLang, getLang, setLang, t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SettingsData {
|
||||
@@ -11,6 +25,364 @@ interface SettingsData {
|
||||
search?: boolean;
|
||||
model?: string;
|
||||
editMode?: string;
|
||||
proNext?: boolean;
|
||||
budgetUsd?: number | null;
|
||||
/** Cumulative session spend (USD); null when no session is attached. */
|
||||
sessionSpendUsd?: number | null;
|
||||
}
|
||||
|
||||
function fmtUsd2(n: number): string {
|
||||
return `$${n.toFixed(n < 1 ? 4 : 2)}`;
|
||||
}
|
||||
|
||||
interface ModelPriceEntry {
|
||||
inputCacheHit: number;
|
||||
inputCacheMiss: number;
|
||||
output: number;
|
||||
}
|
||||
|
||||
interface ModelCatalog {
|
||||
models: string[] | null;
|
||||
current: string | null;
|
||||
pricing: Record<string, ModelPriceEntry>;
|
||||
}
|
||||
|
||||
function formatPricing(p: ModelPriceEntry | undefined): string | null {
|
||||
if (!p) return null;
|
||||
return t("settings.modelPricingLine", {
|
||||
hit: p.inputCacheHit.toFixed(3),
|
||||
miss: p.inputCacheMiss.toFixed(3),
|
||||
out: p.output.toFixed(3),
|
||||
});
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
current,
|
||||
catalog,
|
||||
saving,
|
||||
onPick,
|
||||
}: {
|
||||
current: string;
|
||||
catalog: ModelCatalog | null;
|
||||
saving: boolean;
|
||||
onPick: (model: string) => void;
|
||||
}) {
|
||||
const list = catalog?.models ?? null;
|
||||
const ready = list && list.length > 0;
|
||||
if (!ready) {
|
||||
// Fallback: catalog hasn't loaded (or API failed). Read-only — same as before D-4.
|
||||
return html`<code class="mono">${current ?? "—"}</code>`;
|
||||
}
|
||||
// Ensure the live model is selectable even if the catalog hasn't reported it
|
||||
// yet (preset overrides, custom IDs).
|
||||
const options = list.includes(current) ? list : [current, ...list];
|
||||
const price = catalog?.pricing[current];
|
||||
return html`
|
||||
<span style="display:inline-flex;flex-direction:column;gap:4px">
|
||||
<select
|
||||
value=${current}
|
||||
onChange=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
if (next && next !== current) onPick(next);
|
||||
}}
|
||||
disabled=${saving}
|
||||
style="font-family:var(--font-mono);min-width:200px"
|
||||
>
|
||||
${options.map((m) => html`<option key=${m} value=${m}>${m}</option>`)}
|
||||
</select>
|
||||
${
|
||||
price
|
||||
? html`<span style="color:var(--fg-3);font-size:11px;font-family:var(--font-mono)">${formatPricing(price)}</span>`
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function BudgetGauge({ state }: { state: BudgetState }) {
|
||||
if (state.kind === "off") return null;
|
||||
const tone = budgetTone(state);
|
||||
const fill = Math.min(100, state.pct);
|
||||
const valueColor =
|
||||
tone === "err"
|
||||
? "color:var(--c-err)"
|
||||
: tone === "warn"
|
||||
? "color:var(--c-warn)"
|
||||
: "color:var(--fg-1)";
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;font-size:13px">
|
||||
<span style=${valueColor}>
|
||||
<strong style="font-family:var(--font-mono)">${fmtUsd2(state.spent)}</strong>
|
||||
<span style="color:var(--fg-3)"> ${t("settings.budgetOf")} </span>
|
||||
<strong style="font-family:var(--font-mono)">${fmtUsd2(state.cap)}</strong>
|
||||
</span>
|
||||
<span style=${`font-family:var(--font-mono);font-size:11px;${valueColor}`}>${state.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class=${`progress ${tone}`}><div class="progress-fill" style=${`width:${fill}%`}></div></div>
|
||||
<span style="color:var(--fg-3);font-size:11px">
|
||||
${
|
||||
state.kind === "exhausted"
|
||||
? t("settings.budgetRefusing")
|
||||
: state.kind === "warn"
|
||||
? t("settings.budgetWarnLine")
|
||||
: t("settings.budgetIdleLine")
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface BudgetSectionProps {
|
||||
state: BudgetState;
|
||||
saving: boolean;
|
||||
onSetCap: (usd: number) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function BudgetSection({ state, saving, onSetCap, onClear }: BudgetSectionProps) {
|
||||
const [custom, setCustom] = useState("");
|
||||
const submitCustom = () => {
|
||||
const n = Number.parseFloat(custom);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
onSetCap(n);
|
||||
setCustom("");
|
||||
}
|
||||
};
|
||||
|
||||
const quickButtons = (caps: ReadonlyArray<number>) =>
|
||||
caps.map(
|
||||
(c) => html`
|
||||
<button
|
||||
key=${c}
|
||||
class="btn"
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
onClick=${() => onSetCap(c)}
|
||||
>$${c}</button>
|
||||
`,
|
||||
);
|
||||
|
||||
const customField = html`
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.budgetCustom")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value=${custom}
|
||||
placeholder="0.00"
|
||||
onInput=${(e: Event) => setCustom((e.target as HTMLInputElement).value)}
|
||||
onKeyDown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") submitCustom();
|
||||
}}
|
||||
style="width:72px;font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
/>
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled=${saving || !(Number.parseFloat(custom) > 0)}
|
||||
onClick=${submitCustom}
|
||||
>→</button>
|
||||
</span>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:12px">
|
||||
<${BudgetGauge} state=${state} />
|
||||
|
||||
${
|
||||
state.kind === "off"
|
||||
? html`
|
||||
<div>
|
||||
<div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t("settings.budgetSetCap")}</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${quickButtons(QUICK_CAPS_USD)}
|
||||
${customField}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: state.kind === "warn" || state.kind === "exhausted"
|
||||
? html`
|
||||
<div>
|
||||
<div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t("settings.budgetBumpHint")}</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${bumpSuggestions(state.cap).map(
|
||||
(next) => html`
|
||||
<button
|
||||
key=${next}
|
||||
class="btn primary"
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
onClick=${() => onSetCap(next)}
|
||||
>→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
|
||||
`,
|
||||
)}
|
||||
${customField}
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="btn" disabled=${saving} onClick=${onClear}>${t("settings.budgetClear")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${bumpSuggestions(state.cap).map(
|
||||
(next) => html`
|
||||
<button
|
||||
key=${next}
|
||||
class="btn"
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
onClick=${() => onSetCap(next)}
|
||||
>→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
|
||||
`,
|
||||
)}
|
||||
${customField}
|
||||
<button
|
||||
class="btn"
|
||||
style="margin-left:8px"
|
||||
disabled=${saving}
|
||||
onClick=${onClear}
|
||||
>${t("settings.budgetClear")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface LoopSectionProps {
|
||||
status: LoopRunStatus | null;
|
||||
/** ms remaining until next fire — ticks down client-side between status polls. */
|
||||
remainingMs: number;
|
||||
/** Last-turn cost in USD; used as a hint for "each iteration costs ~". */
|
||||
avgIterCostUsd: number | null;
|
||||
busy: boolean;
|
||||
onStart: (intervalMs: number, prompt: string) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
function LoopSection({
|
||||
status,
|
||||
remainingMs,
|
||||
avgIterCostUsd,
|
||||
busy,
|
||||
onStart,
|
||||
onStop,
|
||||
}: LoopSectionProps) {
|
||||
const [intervalMs, setIntervalMs] = useState<number>(INTERVAL_PRESETS_MS[1]!.ms);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
const [customUnit, setCustomUnit] = useState<IntervalUnit>("m");
|
||||
|
||||
if (status) {
|
||||
return html`
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline">
|
||||
<span style="color:var(--c-warn);font-family:var(--font-mono);font-size:11px">⟳ ${t("settings.loopRunning")}</span>
|
||||
<span style="color:var(--fg-3);font-size:11px">
|
||||
${t("settings.loopIter", { iter: status.iter })} · ${t("settings.loopFiresIn", { remaining: formatRemaining(remainingMs) })}
|
||||
</span>
|
||||
</div>
|
||||
<div style="background:var(--bg-elev-2);border:1px solid var(--bd);border-radius:var(--r);padding:8px 10px;font-family:var(--font-mono);font-size:12px;color:var(--fg-1);white-space:pre-wrap;max-height:120px;overflow-y:auto">${status.prompt}</div>
|
||||
<div>
|
||||
<button class="btn danger" disabled=${busy} onClick=${onStop}>${t("settings.loopStop")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const customMs = parseCustomInterval(customValue, customUnit);
|
||||
const canStart = !busy && intervalMs > 0 && prompt.trim().length > 0;
|
||||
|
||||
return html`
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="color:var(--fg-3);font-size:11px">
|
||||
${t("settings.loopIdleHint")}
|
||||
${
|
||||
typeof avgIterCostUsd === "number" && avgIterCostUsd > 0
|
||||
? html` ${t("settings.loopCostHint", { cost: `$${avgIterCostUsd.toFixed(4)}` })}`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopInterval")}</span>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${INTERVAL_PRESETS_MS.map(
|
||||
(p) => html`
|
||||
<button
|
||||
key=${p.ms}
|
||||
class=${`btn ${intervalMs === p.ms && customValue === "" ? "primary" : ""}`}
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${busy}
|
||||
onClick=${() => {
|
||||
setIntervalMs(p.ms);
|
||||
setCustomValue("");
|
||||
}}
|
||||
>${p.label}</button>
|
||||
`,
|
||||
)}
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopCustom")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value=${customValue}
|
||||
onInput=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
setCustomValue(raw);
|
||||
const ms = parseCustomInterval(raw, customUnit);
|
||||
if (ms !== null) setIntervalMs(ms);
|
||||
}}
|
||||
style="width:64px;font-family:var(--font-mono)"
|
||||
disabled=${busy}
|
||||
/>
|
||||
<select
|
||||
value=${customUnit}
|
||||
onChange=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value as IntervalUnit;
|
||||
setCustomUnit(next);
|
||||
if (customValue) {
|
||||
const ms = parseCustomInterval(customValue, next);
|
||||
if (ms !== null) setIntervalMs(ms);
|
||||
}
|
||||
}}
|
||||
disabled=${busy}
|
||||
>
|
||||
<option value="s">s</option>
|
||||
<option value="m">m</option>
|
||||
<option value="h">h</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
${
|
||||
customValue && customMs === null
|
||||
? html`<span style="color:var(--c-err);font-size:11px">${t("settings.loopRangeError")}</span>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopPrompt")}</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
placeholder=${t("settings.loopPromptPlaceholder")}
|
||||
value=${prompt}
|
||||
onInput=${(e: Event) => setPrompt((e.target as HTMLTextAreaElement).value)}
|
||||
style="width:100%;font-family:var(--font-mono);resize:vertical"
|
||||
disabled=${busy}
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled=${!canStart}
|
||||
onClick=${() => onStart(intervalMs, prompt.trim())}
|
||||
>${t("settings.loopStart")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function SettingsPanel() {
|
||||
@@ -20,6 +392,13 @@ export function SettingsPanel() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Partial<SettingsData>>({});
|
||||
const [catalog, setCatalog] = useState<ModelCatalog | null>(null);
|
||||
const [loopStatus, setLoopStatus] = useState<LoopRunStatus | null>(null);
|
||||
const [loopAvgCost, setLoopAvgCost] = useState<number | null>(null);
|
||||
const [loopBusy, setLoopBusy] = useState(false);
|
||||
/** Wall-clock time of the last status sync — used to interpolate the countdown. */
|
||||
const lastStatusSyncRef = useRef<number>(0);
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -33,6 +412,73 @@ export function SettingsPanel() {
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
api<ModelCatalog>("/models")
|
||||
.then(setCatalog)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const refreshLoop = useCallback(async () => {
|
||||
try {
|
||||
const r = await api<{ status: LoopRunStatus | null }>("/loop/status");
|
||||
setLoopStatus(r.status);
|
||||
lastStatusSyncRef.current = Date.now();
|
||||
} catch {
|
||||
/* ignore — status is best-effort */
|
||||
}
|
||||
try {
|
||||
const r = await api<{ stats?: { lastTurnCostUsd?: number } }>("/overview");
|
||||
setLoopAvgCost(r.stats?.lastTurnCostUsd ?? null);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
refreshLoop();
|
||||
const id = setInterval(() => {
|
||||
if (!cancelled) refreshLoop();
|
||||
}, 5_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [refreshLoop]);
|
||||
useEffect(() => {
|
||||
if (!loopStatus) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => clearInterval(id);
|
||||
}, [loopStatus]);
|
||||
|
||||
const remainingMs = loopStatus
|
||||
? Math.max(0, loopStatus.nextFireMs - (now - lastStatusSyncRef.current))
|
||||
: 0;
|
||||
|
||||
const startLoop = useCallback(
|
||||
async (intervalMs: number, prompt: string) => {
|
||||
setLoopBusy(true);
|
||||
try {
|
||||
await api("/loop/start", { method: "POST", body: { intervalMs, prompt } });
|
||||
await refreshLoop();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoopBusy(false);
|
||||
}
|
||||
},
|
||||
[refreshLoop],
|
||||
);
|
||||
const stopLoop = useCallback(async () => {
|
||||
setLoopBusy(true);
|
||||
try {
|
||||
await api("/loop/stop", { method: "POST" });
|
||||
await refreshLoop();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoopBusy(false);
|
||||
}
|
||||
}, [refreshLoop]);
|
||||
|
||||
const save = useCallback(
|
||||
async (fields: Partial<SettingsData>) => {
|
||||
@@ -189,11 +635,50 @@ export function SettingsPanel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionCompute"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.proNext"),
|
||||
html`
|
||||
<button
|
||||
class=${`btn ${v.proNext ? "primary" : ""}`}
|
||||
onClick=${() => save({ proNext: !v.proNext })}
|
||||
disabled=${saving}
|
||||
>${v.proNext ? t("settings.proArmed") : t("settings.proArm")}</button>
|
||||
`,
|
||||
t("settings.proNextNote"),
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionBudget"))}
|
||||
<${BudgetSection}
|
||||
state=${deriveBudgetState(v.budgetUsd, v.sessionSpendUsd)}
|
||||
saving=${saving}
|
||||
onSetCap=${(usd: number) => save({ budgetUsd: usd })}
|
||||
onClear=${() => save({ budgetUsd: null })}
|
||||
/>
|
||||
|
||||
${sectionH3(t("settings.sectionLoop"))}
|
||||
<${LoopSection}
|
||||
status=${loopStatus}
|
||||
remainingMs=${remainingMs}
|
||||
avgIterCostUsd=${loopAvgCost}
|
||||
busy=${loopBusy}
|
||||
onStart=${startLoop}
|
||||
onStop=${stopLoop}
|
||||
/>
|
||||
|
||||
${sectionH3(t("settings.sectionRuntime"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.activeModel"),
|
||||
html`<code class="mono">${v.model ?? "—"}</code>`,
|
||||
html`<${ModelRow}
|
||||
current=${v.model ?? "—"}
|
||||
catalog=${catalog}
|
||||
saving=${saving}
|
||||
onPick=${(m: string) => save({ model: m })}
|
||||
/>`,
|
||||
t("settings.appliesNextTurn"),
|
||||
)}
|
||||
${fieldRow(
|
||||
t("settings.editMode"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
${
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 320" role="img" aria-label="Reasonix feature grid — six cards covering cache-first loop, plan mode, MCP, sessions, hooks, memory and skills">
|
||||
<title>Reasonix feature grid</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 570" role="img" aria-label="Reasonix capability grid — twelve features covering renderer, MCP, plan mode, permissions, dashboard, sessions, hooks, semantic search, checkpoints, effort knob, replay, event log">
|
||||
<title>Reasonix capabilities</title>
|
||||
<defs>
|
||||
<style>
|
||||
.sans { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif; }
|
||||
.ic { font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
|
||||
.ttl { font-weight: 700; }
|
||||
.sans { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif; }
|
||||
.mono { font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
|
||||
.ttl { font-weight: 700; }
|
||||
</style>
|
||||
<linearGradient id="ig" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#5eead4"/>
|
||||
@@ -14,50 +14,90 @@
|
||||
</defs>
|
||||
|
||||
<g transform="translate(10, 10)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">◈</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">Cache-first loop</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">Byte-stable prompt prefix.</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">94% live cache hit on tau-bench.</text>
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">◈</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Cell-diff renderer</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">Custom TUI on Yoga, no Ink.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Wide-char · emoji · paste · resize-clean.</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(300, 10)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">✎</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">Plan mode · edit review</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">Read-only audit gate before</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">writes. Nothing on disk until /apply.</text>
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">⊕</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">MCP first-class</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">stdio · Streamable HTTP transports.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Tools, resources, prompts.</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(590, 10)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">⊕</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">MCP first-class</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">stdio · SSE · Streamable HTTP.</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">Same cache-safety as native tools.</text>
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">✎</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Plan mode</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">Review proposed edits before writes.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Plan checkpoints persist across runs.</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(10, 170)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">⌨</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">Sessions · dashboard</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">Per-workspace persistent log.</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">/dashboard opens a TUI mirror.</text>
|
||||
<g transform="translate(10, 150)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">⊞</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Permissions</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">allow · ask · deny per tool.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Granular shell rules. Teachable.</text>
|
||||
</g>
|
||||
<g transform="translate(300, 150)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">▣</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Embedded dashboard</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">Live cache hit · cost · session timeline</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">· MCP health. localhost companion.</text>
|
||||
</g>
|
||||
<g transform="translate(590, 150)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">⌨</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Persistent sessions</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">Per-workspace, named, resumable.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">--resume restores state fully.</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(300, 170)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">⚙</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">Hooks</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">Shell scripts on Pre/Post tool.</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">6 lifecycle events, harness-run.</text>
|
||||
<g transform="translate(10, 290)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">⚙</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Hooks · skills · memory</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">Shell on lifecycle events.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Skill packs + project memory.</text>
|
||||
</g>
|
||||
<g transform="translate(300, 290)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">◎</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Semantic search</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">reasonix index builds embeddings.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Local Ollama or DeepSeek-hosted.</text>
|
||||
</g>
|
||||
<g transform="translate(590, 290)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">↺</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Auto-checkpoints</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">Cursor-style session-scoped rollback.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Never pollutes git history.</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(590, 170)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">¶</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">Memory · skills</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">REASONIX.md + private user mem.</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">Skill packs spawn sub-agents.</text>
|
||||
<g transform="translate(10, 430)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">◐</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">/effort knob</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">Switch reasoning depth per turn.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Slash command and CLI flag.</text>
|
||||
</g>
|
||||
<g transform="translate(300, 430)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">▶</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Transcript replay</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">reasonix replay re-renders sessions.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Bug reports, demos, audits.</text>
|
||||
</g>
|
||||
<g transform="translate(590, 430)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">¶</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Event log</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">events.jsonl sidecar + reducers.</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Build dashboards or analytics.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -1,10 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 320" role="img" aria-label="Reasonix 功能一览 — 缓存优先循环、计划模式、MCP、会话、Hooks、Memory 与 Skills">
|
||||
<title>Reasonix 功能一览</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 570" role="img" aria-label="Reasonix 能力一览 — 12 张卡片:渲染器、MCP、计划模式、权限、仪表盘、会话、Hooks、语义检索、checkpoint、effort 旋钮、重放、事件日志">
|
||||
<title>Reasonix 能力一览</title>
|
||||
<defs>
|
||||
<style>
|
||||
.sans { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif; }
|
||||
.ic { font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
|
||||
.ttl { font-weight: 700; }
|
||||
.sans { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif; }
|
||||
.mono { font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
|
||||
.ttl { font-weight: 700; }
|
||||
</style>
|
||||
<linearGradient id="ig" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#5eead4"/>
|
||||
@@ -14,50 +14,90 @@
|
||||
</defs>
|
||||
|
||||
<g transform="translate(10, 10)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">◈</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">缓存优先的循环</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">prompt 前缀字节稳定。</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">tau-bench 上 94% 实测命中。</text>
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">◈</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">自研 cell-diff 渲染器</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">基于 Yoga,不依赖 Ink。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">宽字符 · emoji · 粘贴 · resize 全干净。</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(300, 10)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">✎</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">计划模式 · 编辑审查</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">写盘前先过只读审计闸。</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">不 /apply 一行都不改。</text>
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">⊕</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">MCP 一等公民</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">stdio · Streamable HTTP 双传输。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">工具 / 资源 / 提示词全套。</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(590, 10)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">⊕</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">MCP 一等公民</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">stdio · SSE · Streamable HTTP。</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">和原生工具一样保缓存。</text>
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">✎</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">计划模式</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">修改在落盘前先 review。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Plan checkpoint 跨运行持久化。</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(10, 170)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">⌨</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">会话 · 仪表盘</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">按工作区持久化日志。</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">/dashboard 开浏览器镜像 TUI。</text>
|
||||
<g transform="translate(10, 150)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">⊞</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">权限系统</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">每个工具 allow / ask / deny。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">shell 命令粒度规则,可教。</text>
|
||||
</g>
|
||||
<g transform="translate(300, 150)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">▣</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">内嵌仪表盘</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">实时缓存命中、成本、会话时间线、</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">MCP 健康。localhost 伴生面板。</text>
|
||||
</g>
|
||||
<g transform="translate(590, 150)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">⌨</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">持久化会话</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">按工作区组织、命名、可恢复。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">--resume 完全还原状态。</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(300, 170)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">⚙</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">Hooks</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">Pre/Post tool 时跑 shell 脚本。</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">6 个生命周期事件,由 harness 触发。</text>
|
||||
<g transform="translate(10, 290)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">⚙</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Hooks · Skills · Memory</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">生命周期事件触发 shell。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">Skill 包 + 项目级 memory。</text>
|
||||
</g>
|
||||
<g transform="translate(300, 290)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">◎</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">语义检索</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">reasonix index 构建 embedding 索引。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">本地 Ollama 或 DeepSeek 托管。</text>
|
||||
</g>
|
||||
<g transform="translate(590, 290)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">↺</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">自动 checkpoint</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">Cursor 风格会话级 AI 编辑回滚。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">不污染 git 历史。</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(590, 170)">
|
||||
<rect width="280" height="140" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="44" class="ic" font-size="24" fill="url(#ig)">¶</text>
|
||||
<text x="56" y="42" class="sans ttl" font-size="15" fill="#e6edf3">Memory · Skills</text>
|
||||
<text x="22" y="78" class="sans" font-size="13" fill="#8b949e">REASONIX.md 加用户私有 memory。</text>
|
||||
<text x="22" y="98" class="sans" font-size="13" fill="#8b949e">Skill 包可起 sub-agent 执行。</text>
|
||||
<g transform="translate(10, 430)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">◐</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">/effort 旋钮</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">每回合切换 reasoning 深度。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">斜杠命令 + CLI flag 双入口。</text>
|
||||
</g>
|
||||
<g transform="translate(300, 430)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">▶</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">Transcript 重放</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">reasonix replay 重渲染会话。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">bug 复现、演示、审计。</text>
|
||||
</g>
|
||||
<g transform="translate(590, 430)">
|
||||
<rect width="280" height="130" rx="10" ry="10" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="22" y="42" class="mono" font-size="22" fill="url(#ig)">¶</text>
|
||||
<text x="54" y="40" class="sans ttl" font-size="14" fill="#e6edf3">事件日志</text>
|
||||
<text x="22" y="72" class="sans" font-size="12" fill="#8b949e">events.jsonl 旁路日志 + reducer。</text>
|
||||
<text x="22" y="92" class="sans" font-size="12" fill="#8b949e">自建仪表盘或分析。</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 430 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640" role="img" aria-label="Reasonix — DeepSeek-native AI coding agent for your terminal">
|
||||
<title>Reasonix social card</title>
|
||||
<defs>
|
||||
<style>
|
||||
.mono { font-family: Consolas, "Courier New", monospace; font-weight: 500; }
|
||||
.sans { font-family: "Segoe UI", Arial, sans-serif; }
|
||||
</style>
|
||||
<linearGradient id="brand" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5eead4"/>
|
||||
<stop offset="50%" stop-color="#93c5fd"/>
|
||||
<stop offset="100%" stop-color="#c4b5fd"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bgfade" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0a0c10"/>
|
||||
<stop offset="100%" stop-color="#11141a"/>
|
||||
</linearGradient>
|
||||
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="#1e2436" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="1280" height="640" fill="url(#bgfade)"/>
|
||||
<rect width="1280" height="640" fill="url(#grid)" opacity="0.4"/>
|
||||
|
||||
<g transform="translate(80, 96)">
|
||||
<text class="sans" font-size="22" font-weight="600" letter-spacing="6" fill="#5eead4" opacity="0.85">REASONIX</text>
|
||||
<text y="100" class="sans" font-size="84" font-weight="800" fill="#e6edf3" letter-spacing="-2">DeepSeek-native</text>
|
||||
<text y="184" class="sans" font-size="84" font-weight="800" fill="url(#brand)" letter-spacing="-2">AI coding agent.</text>
|
||||
<text y="244" class="sans" font-size="26" fill="#8b949e">Engineered around prefix-cache stability — leave it running.</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(80, 432)">
|
||||
<rect x="0" y="0" width="1120" height="128" rx="10" fill="#0d1117" stroke="#1e2436"/>
|
||||
<circle cx="22" cy="20" r="6" fill="#ff8b81"/>
|
||||
<circle cx="42" cy="20" r="6" fill="#f0b07d"/>
|
||||
<circle cx="62" cy="20" r="6" fill="#7ee787"/>
|
||||
<text x="560" y="25" text-anchor="middle" class="mono" font-size="12" fill="url(#brand)">reasonix code</text>
|
||||
<line x1="0" y1="40" x2="1120" y2="40" stroke="#1e2436"/>
|
||||
<g class="mono" font-size="18">
|
||||
<text x="20" y="74"><tspan fill="#79c0ff">›</tspan><tspan fill="#c9d1d9" xml:space="preserve"> fix the case-sensitivity bug in findByEmail</tspan></text>
|
||||
<text x="20" y="106"><tspan fill="#7ee787">+</tspan><tspan fill="#8b949e" xml:space="preserve"> queued: src/users.ts (1 edit) · </tspan><tspan fill="#d2a8ff">/apply</tspan><tspan fill="#8b949e" xml:space="preserve"> to commit</tspan></text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(80, 360)" class="sans" font-size="16" fill="#8b949e">
|
||||
<text>
|
||||
<tspan fill="#5eead4">●</tspan> Cache-First Loop
|
||||
<tspan dx="24" fill="#93c5fd">●</tspan> R1 Thought Harvesting
|
||||
<tspan dx="24" fill="#c4b5fd">●</tspan> Tool-Call Repair
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<text x="1200" y="600" text-anchor="end" class="mono" font-size="14" fill="#484f58">github.com/esengine/reasonix</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 360" role="img" aria-label="Reasonix four pillars — cache-first loop, R1 thought harvesting, tool-call repair, cost control">
|
||||
<title>Reasonix four pillars</title>
|
||||
<defs>
|
||||
<style>
|
||||
.sans { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif; }
|
||||
.mono { font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
|
||||
.ttl { font-weight: 700; }
|
||||
.num { font-weight: 800; letter-spacing: 0.08em; }
|
||||
</style>
|
||||
<linearGradient id="ig" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#5eead4"/>
|
||||
<stop offset="50%" stop-color="#93c5fd"/>
|
||||
<stop offset="100%" stop-color="#c4b5fd"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform="translate(10, 10)">
|
||||
<rect width="420" height="160" rx="12" ry="12" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="24" y="42" class="mono num" font-size="13" fill="url(#ig)">01 /</text>
|
||||
<text x="24" y="74" class="sans ttl" font-size="20" fill="#e6edf3">Cache-first loop</text>
|
||||
<text x="24" y="106" class="sans" font-size="13" fill="#8b949e">Append-only history with no in-place</text>
|
||||
<text x="24" y="124" class="sans" font-size="13" fill="#8b949e">mutation. Byte prefix survives every tool</text>
|
||||
<text x="24" y="142" class="sans" font-size="13" fill="#8b949e">call — the cache keeps hitting.</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(450, 10)">
|
||||
<rect width="420" height="160" rx="12" ry="12" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="24" y="42" class="mono num" font-size="13" fill="url(#ig)">02 /</text>
|
||||
<text x="24" y="74" class="sans ttl" font-size="20" fill="#e6edf3">R1 thought harvesting</text>
|
||||
<text x="24" y="106" class="sans" font-size="13" fill="#8b949e">Distills reasoning_content into typed plan</text>
|
||||
<text x="24" y="124" class="sans" font-size="13" fill="#8b949e">state — subgoals, hypotheses, rejected paths.</text>
|
||||
<text x="24" y="142" class="sans" font-size="13" fill="#8b949e">Signal kept, noise dropped.</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(10, 190)">
|
||||
<rect width="420" height="160" rx="12" ry="12" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="24" y="42" class="mono num" font-size="13" fill="url(#ig)">03 /</text>
|
||||
<text x="24" y="74" class="sans ttl" font-size="20" fill="#e6edf3">Tool-call repair</text>
|
||||
<text x="24" y="106" class="sans" font-size="13" fill="#8b949e">Schema flatten · JSON repair · scavenge from</text>
|
||||
<text x="24" y="124" class="sans" font-size="13" fill="#8b949e"><think> · truncation. Four strategies for</text>
|
||||
<text x="24" y="142" class="sans" font-size="13" fill="#8b949e">DeepSeek-specific quirks.</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(450, 190)">
|
||||
<rect width="420" height="160" rx="12" ry="12" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="24" y="42" class="mono num" font-size="13" fill="url(#ig)">04 /</text>
|
||||
<text x="24" y="74" class="sans ttl" font-size="20" fill="#e6edf3">Cost control</text>
|
||||
<text x="24" y="106" class="sans" font-size="13" fill="#8b949e">Cache-safe folding · aggressive-fold tier ·</text>
|
||||
<text x="24" y="124" class="sans" font-size="13" fill="#8b949e">model-aware budgets. Context size managed</text>
|
||||
<text x="24" y="142" class="sans" font-size="13" fill="#8b949e">without breaking prefix.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 360" role="img" aria-label="Reasonix 四大支柱 — 缓存优先循环、R1 思维提取、工具调用修复、成本控制">
|
||||
<title>Reasonix 四大支柱</title>
|
||||
<defs>
|
||||
<style>
|
||||
.sans { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "Microsoft YaHei", sans-serif; }
|
||||
.mono { font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
|
||||
.ttl { font-weight: 700; }
|
||||
.num { font-weight: 800; letter-spacing: 0.08em; }
|
||||
</style>
|
||||
<linearGradient id="ig" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#5eead4"/>
|
||||
<stop offset="50%" stop-color="#93c5fd"/>
|
||||
<stop offset="100%" stop-color="#c4b5fd"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform="translate(10, 10)">
|
||||
<rect width="420" height="160" rx="12" ry="12" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="24" y="42" class="mono num" font-size="13" fill="url(#ig)">01 /</text>
|
||||
<text x="24" y="74" class="sans ttl" font-size="20" fill="#e6edf3">缓存优先循环</text>
|
||||
<text x="24" y="106" class="sans" font-size="13" fill="#8b949e">只追加历史,不就地修改。字节前缀</text>
|
||||
<text x="24" y="124" class="sans" font-size="13" fill="#8b949e">跨过每一次工具调用都活着 ——</text>
|
||||
<text x="24" y="142" class="sans" font-size="13" fill="#8b949e">命中率持续。</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(450, 10)">
|
||||
<rect width="420" height="160" rx="12" ry="12" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="24" y="42" class="mono num" font-size="13" fill="url(#ig)">02 /</text>
|
||||
<text x="24" y="74" class="sans ttl" font-size="20" fill="#e6edf3">R1 思维提取</text>
|
||||
<text x="24" y="106" class="sans" font-size="13" fill="#8b949e">把 reasoning_content 蒸馏成结构化</text>
|
||||
<text x="24" y="124" class="sans" font-size="13" fill="#8b949e">plan state —— 子目标、假设、被否决</text>
|
||||
<text x="24" y="142" class="sans" font-size="13" fill="#8b949e">的路径。留信号,去噪声。</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(10, 190)">
|
||||
<rect width="420" height="160" rx="12" ry="12" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="24" y="42" class="mono num" font-size="13" fill="url(#ig)">03 /</text>
|
||||
<text x="24" y="74" class="sans ttl" font-size="20" fill="#e6edf3">工具调用修复</text>
|
||||
<text x="24" y="106" class="sans" font-size="13" fill="#8b949e">Schema 扁平化 · JSON 修复 · <think></text>
|
||||
<text x="24" y="124" class="sans" font-size="13" fill="#8b949e">内 scavenge · 截断处理。四种策略</text>
|
||||
<text x="24" y="142" class="sans" font-size="13" fill="#8b949e">对付 DeepSeek 专属怪癖。</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(450, 190)">
|
||||
<rect width="420" height="160" rx="12" ry="12" fill="#0a0c10" stroke="#1e2436"/>
|
||||
<text x="24" y="42" class="mono num" font-size="13" fill="url(#ig)">04 /</text>
|
||||
<text x="24" y="74" class="sans ttl" font-size="20" fill="#e6edf3">成本控制</text>
|
||||
<text x="24" y="106" class="sans" font-size="13" fill="#8b949e">缓存安全 fold · 激进 fold 层 ·</text>
|
||||
<text x="24" y="124" class="sans" font-size="13" fill="#8b949e">模型感知预算。管上下文规模</text>
|
||||
<text x="24" y="142" class="sans" font-size="13" fill="#8b949e">不破坏前缀。</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -11,23 +11,27 @@
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
"nav.why": "Why",
|
||||
"nav.features": "Features",
|
||||
"nav.quickstart": "Quick start",
|
||||
"nav.benchmarks": "Benchmarks",
|
||||
"nav.community": "Community",
|
||||
"nav.github": "GitHub",
|
||||
|
||||
"hero.badge": "v{version} · DeepSeek V4 · cache-first",
|
||||
"hero.badge": "v{version} · DeepSeek · cache-first · MIT",
|
||||
"hero.title.line1": "DeepSeek-native",
|
||||
"hero.title.line2": "AI coding agent in your terminal",
|
||||
"hero.sub":
|
||||
"Cache-first agent loop for DeepSeek V4 (flash + pro). Edits files as reviewable SEARCH/REPLACE blocks. Ink TUI. MCP first-class. No LangChain.",
|
||||
"Engineered around DeepSeek's prefix-cache so token costs stay low across long sessions. Custom cell-diff renderer. MCP first-class. Open source.",
|
||||
"hero.copy": "Copy",
|
||||
"hero.copy.done": "Copied",
|
||||
"hero.cta.start": "Get started →",
|
||||
"hero.cta.star": "Star on GitHub",
|
||||
"hero.stat.cache": "prefix cache hit rate",
|
||||
"hero.stat.cost": "cheaper vs Claude Sonnet 4.6",
|
||||
"hero.stat.pass": "τ-bench-lite pass rate",
|
||||
"hero.stat.cache.num": "Cache-engineered",
|
||||
"hero.stat.cache": "Byte-stable prefix loop",
|
||||
"hero.stat.lic.num": "Open source",
|
||||
"hero.stat.lic": "MIT, community-developed",
|
||||
"hero.stat.tui.num": "Terminal-native",
|
||||
"hero.stat.tui": "Plus embedded dashboard",
|
||||
|
||||
"term.user":
|
||||
"users.ts findByEmail is case-sensitive — login fails for users with uppercase emails",
|
||||
@@ -37,25 +41,25 @@
|
||||
|
||||
"why.title": "Why Reasonix",
|
||||
"why.sub":
|
||||
"Every abstraction earns its weight against a DeepSeek-specific property — dirt-cheap tokens, R1 traces, automatic prefix caching, JSON mode. Generic wrappers leave these on the table.",
|
||||
"The loop is organized around four architectural pillars. Each one solves a problem generic agent frameworks don't even see — because they were designed for a different cache mechanic.",
|
||||
"why.cache.title": "Cache-first loop",
|
||||
"why.cache.body":
|
||||
"Append-only conversation log keeps the prompt prefix byte-stable across turns, so DeepSeek's automatic cache hits 85–95% — not 40%.",
|
||||
"why.cost.title": "Cost is a pillar",
|
||||
"Append-only history. No in-place mutation, no marker-based compaction. The byte prefix survives every tool call — DeepSeek's prefix-cache keeps hitting turn after turn.",
|
||||
"why.r1.title": "R1 thought harvesting",
|
||||
"why.r1.body":
|
||||
"Distills <code>reasoning_content</code> into a typed plan state — subgoals, hypotheses, uncertainties, rejected paths. Signal kept, noise dropped.",
|
||||
"why.repair.title": "Tool-call repair",
|
||||
"why.repair.body":
|
||||
"Schema flatten · JSON repair · scavenge from <code><think></code> · truncation. Four strategies that handle DeepSeek-specific quirks generic loops mistake for model errors.",
|
||||
"why.cost.title": "Cost control",
|
||||
"why.cost.body":
|
||||
"Flash-first defaults, turn-end auto-compaction, model self-escalation when (and only when) needed. Per-turn cost badge in the TUI.",
|
||||
"why.tui.title": "Ink TUI, no web",
|
||||
"why.tui.body":
|
||||
"Lives in your terminal. Streaming preview, slash commands, plan picker, edit-confirm modal. Plain mode for stubborn Windows shells.",
|
||||
"why.mcp.title": "MCP first-class",
|
||||
"why.mcp.body":
|
||||
"Stdio + HTTP+SSE transports. Live progress notifications. Same cache-safety + repair plumbing as native tools.",
|
||||
"why.safe.title": "Reviewable edits",
|
||||
"why.safe.body":
|
||||
"Edits arrive as SEARCH/REPLACE blocks; nothing hits disk until /apply. Per-file diff confirm modal, undo history, sandboxed paths.",
|
||||
"why.ctx.title": "1M-token aware",
|
||||
"why.ctx.body":
|
||||
"Context gauge with proactive compaction, oversized tool-result repair, forced-summary fallback near the window edge.",
|
||||
"Cache-safe folding · aggressive-fold tier · summary-on-exit · model-aware budgets. The loop manages context size without breaking prefix stability.",
|
||||
"why.deepseek.title": "DeepSeek-only by design",
|
||||
"why.deepseek.body":
|
||||
"Every layer is tuned around DeepSeek's specific cache mechanic and pricing. Coupling to one backend is the feature, not a limitation.",
|
||||
"why.oss.title": "Open community",
|
||||
"why.oss.body":
|
||||
"MIT licensed and community-developed. Scoped <code>good first issue</code> tickets with code pointers and acceptance criteria. Real PRs from real contributors.",
|
||||
|
||||
"qs.title": "Quick start (60 seconds)",
|
||||
"qs.step1.title": "Get a DeepSeek API key",
|
||||
@@ -64,63 +68,91 @@
|
||||
"qs.step2.title": "Point it at a project",
|
||||
"qs.step2.body": "No install needed.",
|
||||
"qs.step2.note":
|
||||
"First run walks you through a 30-second wizard (paste API key → pick preset → pick MCP servers).",
|
||||
"qs.step3.title": "Ask it to edit",
|
||||
"First run walks you through a short wizard — paste API key, pick a preset, optionally attach MCP servers.",
|
||||
"qs.step3.title": "Review and apply",
|
||||
"qs.step3.body":
|
||||
"The model proposes edits as SEARCH/REPLACE blocks — nothing hits disk until you <code>/apply</code>.",
|
||||
"The agent proposes edits as reviewable blocks — nothing hits disk until you <code>/apply</code>. Plan mode lets you stage multi-file changes before committing any.",
|
||||
"qs.req":
|
||||
"Requires Node ≥ 22. macOS, Linux, Windows (PowerShell · Git Bash · Windows Terminal). Press <kbd>Esc</kbd> anytime to abort; <code>/help</code> for the full command list.",
|
||||
"Requires Node ≥ 22. macOS, Linux, Windows (PowerShell · Git Bash · Windows Terminal). Press <kbd>Esc</kbd> anytime to abort; <code>/help</code> for the full slash-command list.",
|
||||
|
||||
"feat.title": "In the box",
|
||||
"feat.code.title": "Pair programmer mode",
|
||||
"feat.code.body":
|
||||
"<code>read_file</code> · <code>write_file</code> · <code>edit_file</code> · <code>search_files</code> · <code>directory_tree</code> · <code>run_command</code> with read-only allowlist. Sandboxed to the launch root — no path escapes. Plan mode and per-edit review modal.",
|
||||
"feat.memory.title": "Two-layer memory",
|
||||
"feat.memory.body":
|
||||
"Committable <code>REASONIX.md</code> for team conventions plus a private per-user <code>~/.reasonix/memory/</code> with global and per-project scopes. The model can write to it via the <code>remember</code> tool.",
|
||||
"feat.skills.title": "User-authored prompt packs",
|
||||
"feat.skills.body":
|
||||
"Drop <code>SKILL.md</code> files anywhere. Names + descriptions are pinned in the prefix; the model picks them autonomously, or you trigger with <code>/skill name</code>.",
|
||||
"feat.hooks.title": "Lifecycle hooks",
|
||||
"feat.hooks.body":
|
||||
"Shell commands fire at <code>PreToolUse</code> · <code>PostToolUse</code> · <code>UserPromptSubmit</code> · <code>Stop</code>. Exit code drives pass / block / warn.",
|
||||
"feat.mcp.title": "Bring your own tools",
|
||||
"feat.sub":
|
||||
"Twelve concrete capabilities. The loop is the foundation; everything below is what you get on top of it.",
|
||||
"feat.renderer.title": "Cell-diff renderer",
|
||||
"feat.renderer.body":
|
||||
"Custom TUI runtime built on Yoga. No Ink dependency. Wide-char, emoji, bracketed paste, and resize handled cleanly across platforms.",
|
||||
"feat.mcp.title": "MCP first-class",
|
||||
"feat.mcp.body":
|
||||
'Wizard catalog, or <code>--mcp "name=cmd"</code> to attach stdio / HTTP+SSE servers on the fly. Live progress bars on long calls.',
|
||||
"feat.web.title": "Web search built-in",
|
||||
"feat.web.body":
|
||||
"<code>web_search</code> + <code>web_fetch</code> via Mojeek — no key, no signup, off-by-flag for offline / CI. Bring-your-own provider via the <code>WebSearchProvider</code> interface.",
|
||||
'Stdio and Streamable HTTP transports. Tools, resources, and prompts. In-app browser to inspect any server\'s surface, plus <code>--mcp "name=cmd"</code> on the fly.',
|
||||
"feat.plan.title": "Plan mode",
|
||||
"feat.plan.body":
|
||||
"Review proposed edits before they touch disk. Approve, refine, or reject. Plan checkpoints persist across runs so you can resume mid-review.",
|
||||
"feat.perm.title": "Permissions",
|
||||
"feat.perm.body":
|
||||
"<code>allow</code> · <code>ask</code> · <code>deny</code> per-tool. Granular shell command rules. Interactive prompts you can teach.",
|
||||
"feat.dash.title": "Embedded dashboard",
|
||||
"feat.dash.body":
|
||||
"Companion web view at <code>localhost</code>. Live cache hit rate, cost ticker, session timeline, MCP health — all in one place.",
|
||||
"feat.sess.title": "Persistent sessions",
|
||||
"feat.sess.body":
|
||||
"Per-workspace, named, resumable. <code>--resume</code> picks up exactly where you left off — system prompt, history, plan state.",
|
||||
"feat.hooks.title": "Hooks · skills · memory",
|
||||
"feat.hooks.body":
|
||||
"Shell commands fire on lifecycle events. Drop-in skill packs spawn sub-agents. Project memory the agent reads on every turn.",
|
||||
"feat.search.title": "Semantic search",
|
||||
"feat.search.body":
|
||||
"<code>reasonix index</code> builds an embedding index your agent can query. Local Ollama or DeepSeek-hosted embeddings.",
|
||||
"feat.ckpt.title": "Auto-checkpoints",
|
||||
"feat.ckpt.body":
|
||||
"Cursor-style session-scoped rollback for AI edits. Never pollutes git history; the checkpoint stack is yours alone.",
|
||||
"feat.effort.title": "<code>/effort</code> knob",
|
||||
"feat.effort.body":
|
||||
"Switch reasoning depth per turn. <code>max</code> for the gnarly, <code>low</code> for routine. Slash command and CLI flag.",
|
||||
"feat.replay.title": "Transcript replay",
|
||||
"feat.replay.body":
|
||||
"<code>reasonix replay</code> plays a recorded session back through the renderer — useful for bug reports, demos, and audits.",
|
||||
"feat.events.title": "Event log",
|
||||
"feat.events.body":
|
||||
"<code>events.jsonl</code> sidecar with reducers and a <code>reasonix events</code> CLI. Build dashboards, audits, or your own analytics.",
|
||||
|
||||
"bench.title": "Verify the cache claim yourself",
|
||||
"bench.sub":
|
||||
"On the same τ-bench-lite workload (8 multi-turn tool-use tasks × 3 repeats = 48 runs per side), live DeepSeek <code>deepseek-chat</code>, sole variable prefix stability:",
|
||||
"bench.col.metric": "metric",
|
||||
"bench.col.baseline": "baseline (cache-hostile)",
|
||||
"bench.col.reasonix": "Reasonix",
|
||||
"bench.col.delta": "delta",
|
||||
"bench.row.cache": "cache hit",
|
||||
"bench.row.cost": "cost / task",
|
||||
"bench.row.pass": "pass rate",
|
||||
"bench.repro.intro": "Reproduce without spending an API credit:",
|
||||
"We don't ship benchmark numbers in marketing copy — they move with model pricing and harness changes, so they live with the harness instead of the README. Reproduce on your own machine:",
|
||||
"bench.repro.note":
|
||||
"The committed JSONL transcripts carry per-turn <code>usage</code>, <code>cost</code>, and <code>prefixHash</code>. Reasonix's prefix hash stays byte-stable across every model call.",
|
||||
"Each committed JSONL transcript carries per-turn <code>usage</code>, <code>cost</code>, and <code>prefixHash</code>. Reasonix's prefix hash stays byte-stable across every model call — that's the whole game.",
|
||||
"bench.link": "Read the τ-bench-lite harness →",
|
||||
|
||||
"cli.title": "CLI at a glance",
|
||||
"cli.code": "coding mode scoped to path",
|
||||
"cli.chat": "chat (uses saved config)",
|
||||
"cli.setup": "reconfigure the wizard",
|
||||
"cli.chat": "interactive chat (saved config)",
|
||||
"cli.run": "one-shot, streams to stdout",
|
||||
"cli.stats": "cross-session cost dashboard",
|
||||
"cli.mcp": "probe a single MCP server",
|
||||
"cli.doctor": "environment health check",
|
||||
"cli.replay": "re-render a recorded session",
|
||||
"cli.diff": "compare two transcripts",
|
||||
"cli.events": "query the event log",
|
||||
"cli.stats": "cross-session usage",
|
||||
"cli.index": "build semantic embedding index",
|
||||
"cli.mcp": "probe one MCP server",
|
||||
"cli.mcplist": "list configured MCP servers",
|
||||
"cli.prune": "clean up old sessions",
|
||||
"cli.flags.intro": "Common flags:",
|
||||
"cli.f.preset": "model + harvest + branch bundle",
|
||||
"cli.f.effort": "reasoning depth for the run",
|
||||
"cli.f.model": "explicit DeepSeek model id",
|
||||
"cli.f.mcp": "attach an MCP server (repeatable)",
|
||||
"cli.f.session": "named session",
|
||||
"cli.f.resume": "pick up the latest session for this workspace",
|
||||
"cli.f.new": "force a fresh session, preserve old",
|
||||
"cli.f.noconf": "ignore ~/.reasonix/config.json (CI)",
|
||||
|
||||
"comm.title": "Built by the community",
|
||||
"comm.sub":
|
||||
"Reasonix is open source and community-developed. Every avatar on the wall below is a real PR that shipped — not a sponsorship slot.",
|
||||
"comm.gfi": "good first issue →",
|
||||
"comm.disc": "Discussions",
|
||||
"comm.contrib": "Contributing guide",
|
||||
|
||||
"ctab.title": "Ready to try?",
|
||||
"ctab.sub": "One <code>npx</code> away. Sandboxed. Reviewable. 98% cheaper.",
|
||||
"ctab.sub": "One <code>npx</code> away. Sandboxed. Reviewable. Open source.",
|
||||
"ctab.gh": "GitHub repository →",
|
||||
"ctab.npm": "npm package",
|
||||
|
||||
@@ -128,33 +160,39 @@
|
||||
"foot.col.project": "Project",
|
||||
"foot.col.docs": "Docs",
|
||||
"foot.col.community": "Community",
|
||||
"foot.changelog": "Changelog",
|
||||
"foot.releases": "Releases",
|
||||
"foot.readme": "README",
|
||||
"foot.readme.zh": "中文 README",
|
||||
"foot.arch": "Architecture",
|
||||
"foot.bench": "Benchmarks",
|
||||
"foot.issues": "Issues",
|
||||
"foot.discuss": "Discussions",
|
||||
"foot.contributors": "Contributors",
|
||||
"foot.copyright": "© 2026 Reasonix · MIT License",
|
||||
},
|
||||
|
||||
zh: {
|
||||
"nav.why": "为什么",
|
||||
"nav.features": "特性",
|
||||
"nav.quickstart": "快速上手",
|
||||
"nav.benchmarks": "性能对比",
|
||||
"nav.community": "社区",
|
||||
"nav.github": "GitHub",
|
||||
|
||||
"hero.badge": "v{version} · DeepSeek V4 · 缓存优先",
|
||||
"hero.badge": "v{version} · DeepSeek · 缓存优先 · MIT",
|
||||
"hero.title.line1": "DeepSeek 原生",
|
||||
"hero.title.line2": "终端里的 AI 编程代理",
|
||||
"hero.sub":
|
||||
"为 DeepSeek V4(flash + pro)打造的缓存优先 agent 循环。编辑以可审查的 SEARCH/REPLACE 块呈现,落盘前必须确认。Ink TUI、原生支持 MCP,不依赖 LangChain。",
|
||||
"围绕 DeepSeek 前缀缓存设计,长会话下 token 成本始终维持在低位。自研 cell-diff 渲染器,MCP 一等公民,完全开源。",
|
||||
"hero.copy": "复制",
|
||||
"hero.copy.done": "已复制",
|
||||
"hero.cta.start": "开始使用 →",
|
||||
"hero.cta.star": "在 GitHub 加星",
|
||||
"hero.stat.cache": "前缀缓存命中率",
|
||||
"hero.stat.cost": "相比 Claude Sonnet 4.6 节省",
|
||||
"hero.stat.pass": "τ-bench-lite 通过率",
|
||||
"hero.stat.cache.num": "缓存工程化",
|
||||
"hero.stat.cache": "字节稳定的前缀循环",
|
||||
"hero.stat.lic.num": "开源",
|
||||
"hero.stat.lic": "MIT 协议,社区共建",
|
||||
"hero.stat.tui.num": "终端原生",
|
||||
"hero.stat.tui": "外加内嵌仪表盘",
|
||||
|
||||
"term.user":
|
||||
"users.ts 里 findByEmail 对大小写敏感导致登录失败,帮我改",
|
||||
@@ -164,25 +202,25 @@
|
||||
|
||||
"why.title": "为什么选 Reasonix",
|
||||
"why.sub":
|
||||
"每个抽象都对应 DeepSeek 的一个具体特性——极低 token 价、R1 推理轨迹、自动前缀缓存、JSON 模式。通用框架把这些机会全留在桌上。",
|
||||
"why.cache.title": "缓存优先的循环",
|
||||
"整个循环围绕四根架构支柱组织。每一根解决的都是通用 agent 框架根本看不见的问题——因为它们是为另一种缓存机制设计的。",
|
||||
"why.cache.title": "缓存优先循环",
|
||||
"why.cache.body":
|
||||
"对话日志只追加不重写,前缀字节级稳定,DeepSeek 的自动前缀缓存命中能稳定打到 85–95%,而不是 40%。",
|
||||
"why.cost.title": "成本是头等公民",
|
||||
"只追加历史。不就地修改,不依赖标记的 compaction。字节前缀跨过每一次工具调用都活着——DeepSeek 的前缀缓存一轮一轮持续命中。",
|
||||
"why.r1.title": "R1 思维提取",
|
||||
"why.r1.body":
|
||||
"把 <code>reasoning_content</code> 蒸馏成结构化 plan state——子目标、假设、不确定性、被否决的路径。留信号,去噪声。",
|
||||
"why.repair.title": "工具调用修复",
|
||||
"why.repair.body":
|
||||
"Schema 扁平化 · JSON 修复 · <code><think></code> 内 scavenge · 截断处理。四种策略对付 DeepSeek 专属怪癖——通用循环会把这些当模型错误。",
|
||||
"why.cost.title": "成本控制",
|
||||
"why.cost.body":
|
||||
"默认 flash 优先、turn 末尾自动压缩、必要时模型自我升级到 pro。TUI 有实时单轮成本徽章。",
|
||||
"why.tui.title": "Ink TUI,不靠浏览器",
|
||||
"why.tui.body":
|
||||
"完全在终端里。流式预览、斜杠命令、计划审阅、编辑确认弹窗。Windows 顽固终端可启用 plain 模式。",
|
||||
"why.mcp.title": "原生 MCP",
|
||||
"why.mcp.body":
|
||||
"stdio + HTTP+SSE 双传输。长任务有实时进度条。和原生工具一样走缓存安全 + 自动修复管线。",
|
||||
"why.safe.title": "编辑可审阅",
|
||||
"why.safe.body":
|
||||
"编辑以 SEARCH/REPLACE 块呈现,/apply 之前不动磁盘。逐文件 diff 确认、撤销历史、路径沙箱。",
|
||||
"why.ctx.title": "百万 token 感知",
|
||||
"why.ctx.body":
|
||||
"上下文仪表 + 主动压缩、超大工具结果自动修复、贴近上限自动总结兜底。",
|
||||
"缓存安全 fold · 激进 fold 层 · 退出时摘要 · 模型感知预算。循环管理上下文规模时不破坏前缀稳定。",
|
||||
"why.deepseek.title": "故意只做 DeepSeek",
|
||||
"why.deepseek.body":
|
||||
"每一层都为 DeepSeek 特定的缓存机制和定价调过。绑死一个后端是 feature,不是限制。",
|
||||
"why.oss.title": "开放社区",
|
||||
"why.oss.body":
|
||||
"MIT 协议,社区共建。<code>good first issue</code> 入门 issue 都带代码定位和验收标准。真实贡献者的真实 PR。",
|
||||
|
||||
"qs.title": "60 秒快速上手",
|
||||
"qs.step1.title": "获取 DeepSeek API Key",
|
||||
@@ -191,63 +229,91 @@
|
||||
"qs.step2.title": "切到项目目录运行",
|
||||
"qs.step2.body": "无需安装。",
|
||||
"qs.step2.note":
|
||||
"首次运行会走 30 秒向导:粘贴 API key → 选预设 → 勾选 MCP 服务器。",
|
||||
"qs.step3.title": "让它改代码",
|
||||
"首次运行会走一个短向导:粘贴 API key、选预设、可选挂载 MCP 服务器。",
|
||||
"qs.step3.title": "审阅再应用",
|
||||
"qs.step3.body":
|
||||
"模型会以 SEARCH/REPLACE 块的形式提出编辑——你不 <code>/apply</code>,磁盘不会被改。",
|
||||
"代理会把改动以可审阅的块呈现——你不 <code>/apply</code>,磁盘不会被改。Plan 模式可以让你先把多文件改动整理好,再统一落盘。",
|
||||
"qs.req":
|
||||
"需要 Node ≥ 22。支持 macOS、Linux、Windows(PowerShell · Git Bash · Windows Terminal)。任何时候按 <kbd>Esc</kbd> 中断;<code>/help</code> 查看完整命令。",
|
||||
"需要 Node ≥ 22。支持 macOS、Linux、Windows(PowerShell · Git Bash · Windows Terminal)。任何时候按 <kbd>Esc</kbd> 中断;<code>/help</code> 查看完整斜杠命令。",
|
||||
|
||||
"feat.title": "开箱即用",
|
||||
"feat.code.title": "结对编程模式",
|
||||
"feat.code.body":
|
||||
"<code>read_file</code> · <code>write_file</code> · <code>edit_file</code> · <code>search_files</code> · <code>directory_tree</code> · 带只读白名单的 <code>run_command</code>。沙箱限定在启动目录——路径逃逸一律拒绝。支持 plan 模式与逐次编辑确认弹窗。",
|
||||
"feat.memory.title": "两层记忆系统",
|
||||
"feat.memory.body":
|
||||
"可提交的 <code>REASONIX.md</code> 承载团队约定,私人 <code>~/.reasonix/memory/</code> 分全局与按项目两个作用域。模型可通过 <code>remember</code> 工具自行写入。",
|
||||
"feat.skills.title": "用户定义的 prompt 包",
|
||||
"feat.skills.body":
|
||||
"把 <code>SKILL.md</code> 丢进任意位置。名字 + 描述会被钉在前缀里,模型可自动调用,或你用 <code>/skill name</code> 手动触发。",
|
||||
"feat.hooks.title": "生命周期钩子",
|
||||
"feat.hooks.body":
|
||||
"在 <code>PreToolUse</code> · <code>PostToolUse</code> · <code>UserPromptSubmit</code> · <code>Stop</code> 四个点触发 shell 命令,退出码决定放行 / 阻断 / 警告。",
|
||||
"feat.mcp.title": "自带工具进来",
|
||||
"feat.sub":
|
||||
"12 个具体能力。循环是地基,下面是地基之上你能直接拿来用的东西。",
|
||||
"feat.renderer.title": "自研 cell-diff 渲染器",
|
||||
"feat.renderer.body":
|
||||
"基于 Yoga 的 TUI 运行时,不依赖 Ink。宽字符、emoji、bracketed paste、resize 跨平台都干净。",
|
||||
"feat.mcp.title": "MCP 一等公民",
|
||||
"feat.mcp.body":
|
||||
'向导内置目录,或用 <code>--mcp "name=cmd"</code> 直接挂 stdio / HTTP+SSE 服务器。长任务渲染实时进度条。',
|
||||
"feat.web.title": "内置联网搜索",
|
||||
"feat.web.body":
|
||||
"<code>web_search</code> + <code>web_fetch</code>,背靠 Mojeek——无需 key、无需注册,离线 / CI 可关闭。可通过 <code>WebSearchProvider</code> 接口接入自家搜索。",
|
||||
'stdio 与 Streamable HTTP 双传输。工具、资源、提示词全套。内置浏览器查看任意服务器的接口,也能用 <code>--mcp "name=cmd"</code> 现挂。',
|
||||
"feat.plan.title": "计划模式",
|
||||
"feat.plan.body":
|
||||
"改动落盘前先 review。批准、调整、拒绝。Plan checkpoint 跨运行持久化,中途中断也能续。",
|
||||
"feat.perm.title": "权限系统",
|
||||
"feat.perm.body":
|
||||
"每个工具 <code>allow</code> · <code>ask</code> · <code>deny</code>。shell 命令粒度规则。交互式提示,可以教它。",
|
||||
"feat.dash.title": "内嵌仪表盘",
|
||||
"feat.dash.body":
|
||||
"<code>localhost</code> 的伴生 web 面板。实时缓存命中、成本计数、会话时间线、MCP 健康,一处看全。",
|
||||
"feat.sess.title": "持久化会话",
|
||||
"feat.sess.body":
|
||||
"按工作区组织,命名、可恢复。<code>--resume</code> 完全还原——系统提示、历史、plan state。",
|
||||
"feat.hooks.title": "Hooks · Skills · Memory",
|
||||
"feat.hooks.body":
|
||||
"生命周期事件触发 shell 命令。drop-in 的 skill 包能拉子代理。每回合自动读入的项目级 memory。",
|
||||
"feat.search.title": "语义检索",
|
||||
"feat.search.body":
|
||||
"<code>reasonix index</code> 构建 embedding 索引供 agent 查询。本地 Ollama 或 DeepSeek 托管 embedding 任选。",
|
||||
"feat.ckpt.title": "自动 checkpoint",
|
||||
"feat.ckpt.body":
|
||||
"Cursor 风格的会话级 AI 编辑回滚。不污染 git 历史;checkpoint 栈完全是你自己的。",
|
||||
"feat.effort.title": "<code>/effort</code> 旋钮",
|
||||
"feat.effort.body":
|
||||
"每回合切换 reasoning 深度。难活 <code>max</code>、日常 <code>low</code>。斜杠命令 + CLI flag 双入口。",
|
||||
"feat.replay.title": "Transcript 重放",
|
||||
"feat.replay.body":
|
||||
"<code>reasonix replay</code> 把录制好的会话用渲染器重放一遍——bug 复现、演示、审计都好用。",
|
||||
"feat.events.title": "事件日志",
|
||||
"feat.events.body":
|
||||
"<code>events.jsonl</code> 旁路日志,附带 reducer 和 <code>reasonix events</code> CLI。自己搭仪表盘、审计、分析都行。",
|
||||
|
||||
"bench.title": "缓存命中率自己也能验证",
|
||||
"bench.title": "缓存命中自己也能验证",
|
||||
"bench.sub":
|
||||
"同一 τ-bench-lite 负载(8 个多轮工具调用任务 × 3 次重复 = 每边 48 次运行),实测 DeepSeek <code>deepseek-chat</code>,唯一变量是前缀稳定性:",
|
||||
"bench.col.metric": "指标",
|
||||
"bench.col.baseline": "基线(缓存敌对)",
|
||||
"bench.col.reasonix": "Reasonix",
|
||||
"bench.col.delta": "差值",
|
||||
"bench.row.cache": "缓存命中",
|
||||
"bench.row.cost": "单任务成本",
|
||||
"bench.row.pass": "通过率",
|
||||
"bench.repro.intro": "无需消耗 API 额度即可复现:",
|
||||
"营销文案里我们不放具体 benchmark 数字——这些数会随模型定价和测试集变化,所以归在 harness 里,不进 README。在自己机器上复现:",
|
||||
"bench.repro.note":
|
||||
"提交进仓库的 JSONL 文件每轮带 <code>usage</code>、<code>cost</code>、<code>prefixHash</code>。Reasonix 的前缀哈希在每次模型调用时都字节稳定。",
|
||||
"提交进仓库的每个 JSONL transcript 都带逐轮 <code>usage</code>、<code>cost</code> 和 <code>prefixHash</code>。Reasonix 的前缀哈希在每次模型调用中都字节稳定——这就是全部把戏。",
|
||||
"bench.link": "查看 τ-bench-lite harness →",
|
||||
|
||||
"cli.title": "CLI 速览",
|
||||
"cli.code": "针对指定路径的编程模式",
|
||||
"cli.chat": "聊天模式(读取已保存配置)",
|
||||
"cli.setup": "重新跑配置向导",
|
||||
"cli.chat": "交互式聊天(读取已保存配置)",
|
||||
"cli.run": "一次性运行,结果流到 stdout",
|
||||
"cli.stats": "跨会话的成本仪表盘",
|
||||
"cli.doctor": "环境健康检查",
|
||||
"cli.replay": "重渲染一段录制的会话",
|
||||
"cli.diff": "比较两段 transcript",
|
||||
"cli.events": "查询事件日志",
|
||||
"cli.stats": "跨会话用量统计",
|
||||
"cli.index": "构建语义 embedding 索引",
|
||||
"cli.mcp": "探测单个 MCP 服务器",
|
||||
"cli.mcplist": "列出已配置的 MCP 服务器",
|
||||
"cli.prune": "清理旧会话",
|
||||
"cli.flags.intro": "常用 flag:",
|
||||
"cli.f.preset": "模型 + harvest + 分支并行 一键组合",
|
||||
"cli.f.effort": "本次运行的 reasoning 深度",
|
||||
"cli.f.model": "显式指定 DeepSeek 模型 ID",
|
||||
"cli.f.mcp": "挂载 MCP 服务器(可重复)",
|
||||
"cli.f.session": "命名会话",
|
||||
"cli.f.resume": "恢复本工作区的最近一个会话",
|
||||
"cli.f.new": "强制开新会话,旧会话保留",
|
||||
"cli.f.noconf": "忽略 ~/.reasonix/config.json(CI 友好)",
|
||||
|
||||
"comm.title": "由社区共建",
|
||||
"comm.sub":
|
||||
"Reasonix 是开源、社区共建的项目。下面墙上每一个头像都对应一次真实合并的 PR——不是赞助位。",
|
||||
"comm.gfi": "good first issue →",
|
||||
"comm.disc": "Discussions",
|
||||
"comm.contrib": "贡献指南",
|
||||
|
||||
"ctab.title": "准备好了吗?",
|
||||
"ctab.sub": "一条 <code>npx</code> 即可开始。沙箱、可审阅、便宜 98%。",
|
||||
"ctab.sub": "一条 <code>npx</code> 即可开始。沙箱、可审阅、完全开源。",
|
||||
"ctab.gh": "GitHub 仓库 →",
|
||||
"ctab.npm": "npm 包",
|
||||
|
||||
@@ -255,12 +321,14 @@
|
||||
"foot.col.project": "项目",
|
||||
"foot.col.docs": "文档",
|
||||
"foot.col.community": "社区",
|
||||
"foot.changelog": "更新日志",
|
||||
"foot.releases": "Releases",
|
||||
"foot.readme": "英文 README",
|
||||
"foot.readme.zh": "中文 README",
|
||||
"foot.arch": "架构文档",
|
||||
"foot.bench": "Benchmarks",
|
||||
"foot.issues": "问题反馈",
|
||||
"foot.discuss": "讨论区",
|
||||
"foot.contributors": "贡献者",
|
||||
"foot.copyright": "© 2026 Reasonix · MIT 协议",
|
||||
},
|
||||
};
|
||||
@@ -312,7 +380,7 @@
|
||||
// visits the site for the first time. Bumping it occasionally on
|
||||
// major version cuts is fine; the npm fetch handles everything else.
|
||||
const VERSION_STORAGE_KEY = "reasonix.version";
|
||||
const VERSION_FALLBACK = "0.16";
|
||||
const VERSION_FALLBACK = "0.26";
|
||||
const versionListeners = [];
|
||||
let currentVersion = VERSION_FALLBACK;
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
<title>Reasonix — DeepSeek-native AI coding agent</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Cache-first agent loop for DeepSeek V4. Ink TUI. MCP first-class. 90%+ prefix cache hit, ~98% cheaper than Claude on the same workload. No LangChain."
|
||||
content="Open-source AI coding agent for your terminal, engineered around DeepSeek's prefix-cache so token costs stay low across long sessions. MCP first-class · plan mode · custom cell-diff renderer."
|
||||
/>
|
||||
<meta name="theme-color" content="#0b0f17" />
|
||||
|
||||
<meta property="og:title" content="Reasonix — DeepSeek-native AI coding agent" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Cache-first agent loop for DeepSeek V4 (flash + pro). Ink TUI. MCP first-class. No LangChain."
|
||||
content="Open-source AI coding agent for your terminal. Engineered around DeepSeek's prefix-cache. MCP first-class · plan mode · embedded dashboard · MIT."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://esengine.github.io/reasonix/" />
|
||||
@@ -44,9 +44,10 @@
|
||||
</a>
|
||||
|
||||
<nav class="nav-links">
|
||||
<a href="#why" data-i18n="nav.why">Why</a>
|
||||
<a href="#features" data-i18n="nav.features">Features</a>
|
||||
<a href="#quickstart" data-i18n="nav.quickstart">Quick start</a>
|
||||
<a href="#benchmarks" data-i18n="nav.benchmarks">Benchmarks</a>
|
||||
<a href="#community" data-i18n="nav.community">Community</a>
|
||||
<a
|
||||
href="https://github.com/esengine/reasonix"
|
||||
target="_blank"
|
||||
@@ -67,15 +68,15 @@
|
||||
<main id="top">
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<div class="badge" data-i18n="hero.badge">v0.16 · DeepSeek V4 · cache-first</div>
|
||||
<div class="badge" data-i18n="hero.badge">v{version} · DeepSeek · cache-first · MIT</div>
|
||||
<h1 class="hero-title">
|
||||
<span class="grad-text" data-i18n="hero.title.line1">DeepSeek-native</span>
|
||||
<br />
|
||||
<span data-i18n="hero.title.line2">AI coding agent in your terminal</span>
|
||||
</h1>
|
||||
<p class="hero-sub" data-i18n="hero.sub">
|
||||
Cache-first agent loop for DeepSeek V4 (flash + pro). Edits files as
|
||||
reviewable SEARCH/REPLACE blocks. Ink TUI. MCP first-class. No LangChain.
|
||||
Engineered around DeepSeek's prefix-cache so token costs stay low across long
|
||||
sessions. Custom cell-diff renderer. MCP first-class. Open source.
|
||||
</p>
|
||||
|
||||
<div class="hero-install">
|
||||
@@ -103,16 +104,16 @@
|
||||
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<div class="stat-num grad-text">90.2%</div>
|
||||
<div class="stat-label" data-i18n="hero.stat.cache">prefix cache hit rate</div>
|
||||
<div class="stat-num grad-text" data-i18n="hero.stat.cache.num">Cache-engineered</div>
|
||||
<div class="stat-label" data-i18n="hero.stat.cache">Byte-stable prefix loop</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-num grad-text">~98%</div>
|
||||
<div class="stat-label" data-i18n="hero.stat.cost">cheaper vs Claude Sonnet 4.6</div>
|
||||
<div class="stat-num grad-text" data-i18n="hero.stat.lic.num">Open source</div>
|
||||
<div class="stat-label" data-i18n="hero.stat.lic">MIT, community-developed</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-num grad-text">100%</div>
|
||||
<div class="stat-label" data-i18n="hero.stat.pass">τ-bench-lite pass rate</div>
|
||||
<div class="stat-num grad-text" data-i18n="hero.stat.tui.num">Terminal-native</div>
|
||||
<div class="stat-label" data-i18n="hero.stat.tui">Plus embedded dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,13 +128,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="why">
|
||||
<section id="why" class="why">
|
||||
<div class="container">
|
||||
<h2 class="section-title" data-i18n="why.title">Why Reasonix</h2>
|
||||
<p class="section-sub" data-i18n="why.sub">
|
||||
Every abstraction earns its weight against a DeepSeek-specific property —
|
||||
dirt-cheap tokens, R1 traces, automatic prefix caching, JSON mode. Generic
|
||||
wrappers leave these on the table.
|
||||
The loop is organized around four architectural pillars. Each one solves a
|
||||
problem generic agent frameworks don't even see — because they were designed
|
||||
for a different cache mechanic.
|
||||
</p>
|
||||
|
||||
<div class="why-grid">
|
||||
@@ -141,48 +142,51 @@
|
||||
<div class="why-icon">◈</div>
|
||||
<h3 data-i18n="why.cache.title">Cache-first loop</h3>
|
||||
<p data-i18n="why.cache.body">
|
||||
Append-only conversation log keeps the prompt prefix byte-stable across
|
||||
turns, so DeepSeek's automatic cache hits 85–95% — not 40%.
|
||||
Append-only history. No in-place mutation, no marker-based compaction.
|
||||
The byte prefix survives every tool call — DeepSeek's prefix-cache keeps
|
||||
hitting turn after turn.
|
||||
</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="why-icon">⌥</div>
|
||||
<h3 data-i18n="why.r1.title">R1 thought harvesting</h3>
|
||||
<p data-i18n="why.r1.body">
|
||||
Distills <code>reasoning_content</code> into a typed plan state — subgoals,
|
||||
hypotheses, uncertainties, rejected paths. Signal kept, noise dropped.
|
||||
</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="why-icon">⚒</div>
|
||||
<h3 data-i18n="why.repair.title">Tool-call repair</h3>
|
||||
<p data-i18n="why.repair.body">
|
||||
Schema flatten · JSON repair · scavenge from <code><think></code> ·
|
||||
truncation. Four strategies that handle DeepSeek-specific quirks generic
|
||||
loops mistake for model errors.
|
||||
</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="why-icon">¥</div>
|
||||
<h3 data-i18n="why.cost.title">Cost is a pillar</h3>
|
||||
<h3 data-i18n="why.cost.title">Cost control</h3>
|
||||
<p data-i18n="why.cost.body">
|
||||
Flash-first defaults, turn-end auto-compaction, model self-escalation
|
||||
when (and only when) needed. Per-turn cost badge in the TUI.
|
||||
Cache-safe folding · aggressive-fold tier · summary-on-exit · model-aware
|
||||
budgets. The loop manages context size without breaking prefix stability.
|
||||
</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="why-icon">⌨</div>
|
||||
<h3 data-i18n="why.tui.title">Ink TUI, no web</h3>
|
||||
<p data-i18n="why.tui.body">
|
||||
Lives in your terminal. Streaming preview, slash commands, plan picker,
|
||||
edit-confirm modal. Plain mode for stubborn Windows shells.
|
||||
<div class="why-icon">∂</div>
|
||||
<h3 data-i18n="why.deepseek.title">DeepSeek-only by design</h3>
|
||||
<p data-i18n="why.deepseek.body">
|
||||
Every layer is tuned around DeepSeek's specific cache mechanic and pricing.
|
||||
Coupling to one backend is the feature, not a limitation.
|
||||
</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="why-icon">⊕</div>
|
||||
<h3 data-i18n="why.mcp.title">MCP first-class</h3>
|
||||
<p data-i18n="why.mcp.body">
|
||||
Stdio + HTTP+SSE transports. Live progress notifications. Same
|
||||
cache-safety + repair plumbing as native tools.
|
||||
</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="why-icon">↶</div>
|
||||
<h3 data-i18n="why.safe.title">Reviewable edits</h3>
|
||||
<p data-i18n="why.safe.body">
|
||||
Edits arrive as SEARCH/REPLACE blocks; nothing hits disk until /apply.
|
||||
Per-file diff confirm modal, undo history, sandboxed paths.
|
||||
</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="why-icon">∞</div>
|
||||
<h3 data-i18n="why.ctx.title">1M-token aware</h3>
|
||||
<p data-i18n="why.ctx.body">
|
||||
Context gauge with proactive compaction, oversized tool-result repair,
|
||||
forced-summary fallback near the window edge.
|
||||
<div class="why-icon">⊜</div>
|
||||
<h3 data-i18n="why.oss.title">Open community</h3>
|
||||
<p data-i18n="why.oss.body">
|
||||
MIT licensed and community-developed. Scoped <code>good first issue</code>
|
||||
tickets with code pointers and acceptance criteria. Real PRs from real
|
||||
contributors.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
@@ -209,15 +213,16 @@
|
||||
<pre class="code"><code>cd my-project
|
||||
npx reasonix code</code></pre>
|
||||
<p data-i18n="qs.step2.note">
|
||||
First run walks you through a 30-second wizard (paste API key → pick
|
||||
preset → pick MCP servers).
|
||||
First run walks you through a short wizard — paste API key, pick a
|
||||
preset, optionally attach MCP servers.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3 data-i18n="qs.step3.title">Ask it to edit</h3>
|
||||
<h3 data-i18n="qs.step3.title">Review and apply</h3>
|
||||
<p data-i18n="qs.step3.body">
|
||||
The model proposes edits as SEARCH/REPLACE blocks — nothing hits disk
|
||||
until you <code>/apply</code>.
|
||||
The agent proposes edits as reviewable blocks — nothing hits disk until
|
||||
you <code>/apply</code>. Plan mode lets you stage multi-file changes
|
||||
before committing any.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -225,7 +230,7 @@ npx reasonix code</code></pre>
|
||||
<p class="req" data-i18n="qs.req">
|
||||
Requires Node ≥ 22. macOS, Linux, Windows (PowerShell · Git Bash · Windows
|
||||
Terminal). Press <kbd>Esc</kbd> anytime to abort; <code>/help</code> for the
|
||||
full command list.
|
||||
full slash-command list.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -233,121 +238,147 @@ npx reasonix code</code></pre>
|
||||
<section id="features" class="features">
|
||||
<div class="container">
|
||||
<h2 class="section-title" data-i18n="feat.title">In the box</h2>
|
||||
<p class="section-sub" data-i18n="feat.sub">
|
||||
Twelve concrete capabilities. The loop is the foundation; everything below
|
||||
is what you get on top of it.
|
||||
</p>
|
||||
|
||||
<div class="feat-grid">
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">code</span>
|
||||
<span data-i18n="feat.code.title">Pair programmer mode</span>
|
||||
<span class="feat-tag">renderer</span>
|
||||
<span data-i18n="feat.renderer.title">Cell-diff renderer</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.code.body">
|
||||
<code>read_file</code> · <code>write_file</code> · <code>edit_file</code> ·
|
||||
<code>search_files</code> · <code>directory_tree</code> ·
|
||||
<code>run_command</code> with read-only allowlist. Sandboxed to the
|
||||
launch root — no path escapes. Plan mode and per-edit review modal.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">memory</span>
|
||||
<span data-i18n="feat.memory.title">Two-layer memory</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.memory.body">
|
||||
Committable <code>REASONIX.md</code> for team conventions plus a private
|
||||
per-user <code>~/.reasonix/memory/</code> with global and per-project
|
||||
scopes. The model can write to it via the <code>remember</code> tool.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">skills</span>
|
||||
<span data-i18n="feat.skills.title">User-authored prompt packs</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.skills.body">
|
||||
Drop <code>SKILL.md</code> files anywhere. Names + descriptions are
|
||||
pinned in the prefix; the model picks them autonomously, or you trigger
|
||||
with <code>/skill name</code>.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">hooks</span>
|
||||
<span data-i18n="feat.hooks.title">Lifecycle hooks</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.hooks.body">
|
||||
Shell commands fire at <code>PreToolUse</code> · <code>PostToolUse</code>
|
||||
· <code>UserPromptSubmit</code> · <code>Stop</code>. Exit code drives
|
||||
pass / block / warn.
|
||||
<p data-i18n="feat.renderer.body">
|
||||
Custom TUI runtime built on Yoga. No Ink dependency. Wide-char, emoji,
|
||||
bracketed paste, and resize handled cleanly across platforms.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">mcp</span>
|
||||
<span data-i18n="feat.mcp.title">Bring your own tools</span>
|
||||
<span data-i18n="feat.mcp.title">MCP first-class</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.mcp.body">
|
||||
Wizard catalog, or <code>--mcp "name=cmd"</code> to attach stdio /
|
||||
HTTP+SSE servers on the fly. Live progress bars on long calls.
|
||||
Stdio and Streamable HTTP transports. Tools, resources, and prompts.
|
||||
In-app browser to inspect any server's surface, plus
|
||||
<code>--mcp "name=cmd"</code> on the fly.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">web</span>
|
||||
<span data-i18n="feat.web.title">Web search built-in</span>
|
||||
<span class="feat-tag">plan</span>
|
||||
<span data-i18n="feat.plan.title">Plan mode</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.web.body">
|
||||
<code>web_search</code> + <code>web_fetch</code> via Mojeek — no key, no
|
||||
signup, off-by-flag for offline / CI. Bring-your-own provider via the
|
||||
<code>WebSearchProvider</code> interface.
|
||||
<p data-i18n="feat.plan.body">
|
||||
Review proposed edits before they touch disk. Approve, refine, or reject.
|
||||
Plan checkpoints persist across runs so you can resume mid-review.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">perms</span>
|
||||
<span data-i18n="feat.perm.title">Permissions</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.perm.body">
|
||||
<code>allow</code> · <code>ask</code> · <code>deny</code> per-tool.
|
||||
Granular shell command rules. Interactive prompts you can teach.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">dashboard</span>
|
||||
<span data-i18n="feat.dash.title">Embedded dashboard</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.dash.body">
|
||||
Companion web view at <code>localhost</code>. Live cache hit rate, cost
|
||||
ticker, session timeline, MCP health — all in one place.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">sessions</span>
|
||||
<span data-i18n="feat.sess.title">Persistent sessions</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.sess.body">
|
||||
Per-workspace, named, resumable. <code>--resume</code> picks up exactly
|
||||
where you left off — system prompt, history, plan state.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">hooks</span>
|
||||
<span data-i18n="feat.hooks.title">Hooks · skills · memory</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.hooks.body">
|
||||
Shell commands fire on lifecycle events. Drop-in skill packs spawn
|
||||
sub-agents. Project memory the agent reads on every turn.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">search</span>
|
||||
<span data-i18n="feat.search.title">Semantic search</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.search.body">
|
||||
<code>reasonix index</code> builds an embedding index your agent can
|
||||
query. Local Ollama or DeepSeek-hosted embeddings.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">checkpoint</span>
|
||||
<span data-i18n="feat.ckpt.title">Auto-checkpoints</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.ckpt.body">
|
||||
Cursor-style session-scoped rollback for AI edits. Never pollutes git
|
||||
history; the checkpoint stack is yours alone.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">effort</span>
|
||||
<span data-i18n="feat.effort.title"><code>/effort</code> knob</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.effort.body">
|
||||
Switch reasoning depth per turn. <code>max</code> for the gnarly,
|
||||
<code>low</code> for routine. Slash command and CLI flag.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">replay</span>
|
||||
<span data-i18n="feat.replay.title">Transcript replay</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.replay.body">
|
||||
<code>reasonix replay</code> plays a recorded session back through the
|
||||
renderer — useful for bug reports, demos, and audits.
|
||||
</p>
|
||||
</article>
|
||||
<article class="feat">
|
||||
<h3>
|
||||
<span class="feat-tag">events</span>
|
||||
<span data-i18n="feat.events.title">Event log</span>
|
||||
</h3>
|
||||
<p data-i18n="feat.events.body">
|
||||
<code>events.jsonl</code> sidecar with reducers and a
|
||||
<code>reasonix events</code> CLI. Build dashboards, audits, or your own
|
||||
analytics.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="benchmarks" class="bench">
|
||||
<section class="bench">
|
||||
<div class="container">
|
||||
<h2 class="section-title" data-i18n="bench.title">Verify the cache claim yourself</h2>
|
||||
<p class="section-sub" data-i18n="bench.sub">
|
||||
On the same τ-bench-lite workload (8 multi-turn tool-use tasks × 3 repeats =
|
||||
48 runs per side), live DeepSeek <code>deepseek-chat</code>, sole variable
|
||||
prefix stability:
|
||||
We don't ship benchmark numbers in marketing copy — they move with model
|
||||
pricing and harness changes, so they live with the harness instead of the
|
||||
README. Reproduce on your own machine:
|
||||
</p>
|
||||
|
||||
<div class="bench-table-wrap">
|
||||
<table class="bench-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="bench.col.metric">metric</th>
|
||||
<th data-i18n="bench.col.baseline">baseline (cache-hostile)</th>
|
||||
<th data-i18n="bench.col.reasonix">Reasonix</th>
|
||||
<th data-i18n="bench.col.delta">delta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td data-i18n="bench.row.cache">cache hit</td>
|
||||
<td>32.8%</td>
|
||||
<td class="hi">90.2%</td>
|
||||
<td>+57.4 pp</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-i18n="bench.row.cost">cost / task</td>
|
||||
<td>$0.000992</td>
|
||||
<td class="hi">$0.000593</td>
|
||||
<td><strong>−40%</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-i18n="bench.row.pass">pass rate</td>
|
||||
<td>100% (24/24)</td>
|
||||
<td class="hi">100% (24/24)</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-i18n="bench.repro.intro">Reproduce without spending an API credit:</p>
|
||||
<pre class="code"><code>git clone https://github.com/esengine/reasonix.git && cd reasonix && npm install
|
||||
npx reasonix replay benchmarks/tau-bench/transcripts/t01_address_happy.reasonix.r1.jsonl
|
||||
npx reasonix diff \
|
||||
@@ -355,29 +386,98 @@ npx reasonix diff \
|
||||
benchmarks/tau-bench/transcripts/t01_address_happy.reasonix.r1.jsonl</code></pre>
|
||||
|
||||
<p data-i18n="bench.repro.note">
|
||||
The committed JSONL transcripts carry per-turn <code>usage</code>,
|
||||
Each committed JSONL transcript carries per-turn <code>usage</code>,
|
||||
<code>cost</code>, and <code>prefixHash</code>. Reasonix's prefix hash stays
|
||||
byte-stable across every model call.
|
||||
byte-stable across every model call — that's the whole game.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
class="cta ghost"
|
||||
href="https://github.com/esengine/reasonix/tree/main/benchmarks"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="bench.link"
|
||||
>Read the τ-bench-lite harness →</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cli">
|
||||
<section id="cli" class="cli">
|
||||
<div class="container">
|
||||
<h2 class="section-title" data-i18n="cli.title">CLI at a glance</h2>
|
||||
<pre class="code"><code>npx reasonix code [path] <span class="hash"># <span data-i18n="cli.code">coding mode scoped to path</span></span>
|
||||
npx reasonix <span class="hash"># <span data-i18n="cli.chat">chat (uses saved config)</span></span>
|
||||
npx reasonix setup <span class="hash"># <span data-i18n="cli.setup">reconfigure the wizard</span></span>
|
||||
npx reasonix chat <span class="hash"># <span data-i18n="cli.chat">interactive chat (saved config)</span></span>
|
||||
npx reasonix run "ask anything" <span class="hash"># <span data-i18n="cli.run">one-shot, streams to stdout</span></span>
|
||||
npx reasonix stats <span class="hash"># <span data-i18n="cli.stats">cross-session cost dashboard</span></span>
|
||||
npx reasonix mcp inspect <spec> <span class="hash"># <span data-i18n="cli.mcp">probe a single MCP server</span></span></code></pre>
|
||||
npx reasonix doctor <span class="hash"># <span data-i18n="cli.doctor">environment health check</span></span>
|
||||
npx reasonix replay <file.jsonl> <span class="hash"># <span data-i18n="cli.replay">re-render a recorded session</span></span>
|
||||
npx reasonix diff a.jsonl b.jsonl <span class="hash"># <span data-i18n="cli.diff">compare two transcripts</span></span>
|
||||
npx reasonix events <name> <span class="hash"># <span data-i18n="cli.events">query the event log</span></span>
|
||||
npx reasonix stats <span class="hash"># <span data-i18n="cli.stats">cross-session usage</span></span>
|
||||
npx reasonix index <span class="hash"># <span data-i18n="cli.index">build semantic embedding index</span></span>
|
||||
npx reasonix mcp inspect <spec> <span class="hash"># <span data-i18n="cli.mcp">probe one MCP server</span></span>
|
||||
npx reasonix mcp list <span class="hash"># <span data-i18n="cli.mcplist">list configured MCP servers</span></span>
|
||||
npx reasonix prune-sessions <span class="hash"># <span data-i18n="cli.prune">clean up old sessions</span></span></code></pre>
|
||||
|
||||
<p class="cli-flags" data-i18n="cli.flags.intro">Common flags:</p>
|
||||
<pre class="code"><code>--preset <fast|smart|max> <span class="hash"># <span data-i18n="cli.f.preset">model + harvest + branch bundle</span></span>
|
||||
--model <id> <span class="hash"># <span data-i18n="cli.f.model">explicit DeepSeek model id</span></span>
|
||||
--mcp "name=cmd args…" <span class="hash"># <span data-i18n="cli.f.mcp">attach an MCP server (repeatable)</span></span>
|
||||
--session <name> <span class="hash"># <span data-i18n="cli.f.session">named session</span></span>
|
||||
--no-config <span class="hash"># <span data-i18n="cli.f.noconf">ignore ~/.reasonix/config.json (CI)</span></span></code></pre>
|
||||
<pre class="code"><code>--effort <max|high|medium|low> <span class="hash"># <span data-i18n="cli.f.effort">reasoning depth for the run</span></span>
|
||||
--model <id> <span class="hash"># <span data-i18n="cli.f.model">explicit DeepSeek model id</span></span>
|
||||
--mcp "name=cmd args…" <span class="hash"># <span data-i18n="cli.f.mcp">attach an MCP server (repeatable)</span></span>
|
||||
--session <name> <span class="hash"># <span data-i18n="cli.f.session">named session</span></span>
|
||||
--resume <span class="hash"># <span data-i18n="cli.f.resume">pick up the latest session for this workspace</span></span>
|
||||
--new <span class="hash"># <span data-i18n="cli.f.new">force a fresh session, preserve old</span></span>
|
||||
--no-config <span class="hash"># <span data-i18n="cli.f.noconf">ignore ~/.reasonix/config.json (CI)</span></span></code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="community" class="community">
|
||||
<div class="container">
|
||||
<h2 class="section-title" data-i18n="comm.title">Built by the community</h2>
|
||||
<p class="section-sub" data-i18n="comm.sub">
|
||||
Reasonix is open source and community-developed. Every avatar on the wall
|
||||
below is a real PR that shipped — not a sponsorship slot.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a
|
||||
href="https://github.com/esengine/reasonix/graphs/contributors"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<img
|
||||
src="https://contrib.rocks/image?repo=esengine/reasonix&max=100&columns=12"
|
||||
alt="Contributors to esengine/reasonix"
|
||||
style="max-width: 100%; border-radius: 8px;"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div class="comm-links">
|
||||
<a
|
||||
class="cta ghost"
|
||||
href="https://github.com/esengine/reasonix/labels/good%20first%20issue"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="comm.gfi"
|
||||
>good first issue →</a
|
||||
>
|
||||
<a
|
||||
class="cta ghost"
|
||||
href="https://github.com/esengine/reasonix/discussions"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="comm.disc"
|
||||
>Discussions</a
|
||||
>
|
||||
<a
|
||||
class="cta ghost"
|
||||
href="https://github.com/esengine/reasonix/blob/main/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="comm.contrib"
|
||||
>Contributing guide</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -385,7 +485,7 @@ npx reasonix mcp inspect <spec> <span class="hash"># <span data-i
|
||||
<div class="container">
|
||||
<h2 data-i18n="ctab.title">Ready to try?</h2>
|
||||
<p data-i18n="ctab.sub">
|
||||
One <code>npx</code> away. Sandboxed. Reviewable. 98% cheaper.
|
||||
One <code>npx</code> away. Sandboxed. Reviewable. Open source.
|
||||
</p>
|
||||
<div class="hero-ctas center">
|
||||
<a
|
||||
@@ -427,11 +527,11 @@ npx reasonix mcp inspect <spec> <span class="hash"># <span data-i
|
||||
<a href="https://github.com/esengine/reasonix" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="https://www.npmjs.com/package/reasonix" target="_blank" rel="noopener">npm</a>
|
||||
<a
|
||||
href="https://github.com/esengine/reasonix/blob/main/CHANGELOG.md"
|
||||
href="https://github.com/esengine/reasonix/releases"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="foot.changelog"
|
||||
>Changelog</a
|
||||
data-i18n="foot.releases"
|
||||
>Releases</a
|
||||
>
|
||||
<a
|
||||
href="https://github.com/esengine/reasonix/blob/main/LICENSE"
|
||||
@@ -463,6 +563,13 @@ npx reasonix mcp inspect <spec> <span class="hash"># <span data-i
|
||||
data-i18n="foot.arch"
|
||||
>Architecture</a
|
||||
>
|
||||
<a
|
||||
href="https://github.com/esengine/reasonix/tree/main/benchmarks"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="foot.bench"
|
||||
>Benchmarks</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<h4 data-i18n="foot.col.community">Community</h4>
|
||||
@@ -480,6 +587,13 @@ npx reasonix mcp inspect <spec> <span class="hash"># <span data-i
|
||||
data-i18n="foot.discuss"
|
||||
>Discussions</a
|
||||
>
|
||||
<a
|
||||
href="https://github.com/esengine/reasonix/graphs/contributors"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="foot.contributors"
|
||||
>Contributors</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -417,9 +417,9 @@ main,
|
||||
}
|
||||
.stat-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-label {
|
||||
@@ -941,9 +941,9 @@ main,
|
||||
|
||||
.feat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
max-width: 980px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -1057,6 +1057,27 @@ main,
|
||||
margin: 24px 0 0;
|
||||
}
|
||||
|
||||
/* Community */
|
||||
.community {
|
||||
padding: 80px 0;
|
||||
}
|
||||
.community .container > p[align="center"] {
|
||||
margin: 24px auto 32px;
|
||||
max-width: 880px;
|
||||
text-align: center;
|
||||
}
|
||||
.community .container > p[align="center"] img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.comm-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* CTA band */
|
||||
.cta-band {
|
||||
padding: 80px 0;
|
||||
@@ -1128,10 +1149,10 @@ main,
|
||||
grid-template-columns: 1fr;
|
||||
gap: 40px;
|
||||
}
|
||||
.why-grid {
|
||||
.why-grid,
|
||||
.feat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.feat-grid,
|
||||
.foot-inner {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1148,7 +1169,8 @@ main,
|
||||
margin: 24px auto 60px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.why-grid {
|
||||
.why-grid,
|
||||
.feat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hero-stats {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.25.0",
|
||||
"version": "0.31.0",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -30,6 +30,8 @@
|
||||
"chat": "tsx src/cli/index.ts chat",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:mutation": "stryker run",
|
||||
"lint": "biome check src tests",
|
||||
"lint:fix": "biome check --write src tests",
|
||||
"format": "biome format --write src tests",
|
||||
@@ -70,24 +72,26 @@
|
||||
"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": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@stryker-mutator/core": "^9.6.1",
|
||||
"@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",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFileSync, appendFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname;
|
||||
const SUMMARY_PATH = join(ROOT, "coverage", "coverage-summary.json");
|
||||
|
||||
const GITHUB_STEP_SUMMARY = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (!GITHUB_STEP_SUMMARY) {
|
||||
console.log("Not running in GitHub Actions (no GITHUB_STEP_SUMMARY). Skipping.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(SUMMARY_PATH, "utf-8"));
|
||||
} catch (err) {
|
||||
console.error("Failed to read coverage summary:", err.message);
|
||||
process.exit(0); // don't fail the build if coverage report is missing
|
||||
}
|
||||
|
||||
const { total } = data;
|
||||
if (!total) {
|
||||
console.error("No 'total' key in coverage summary. Skipping.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const pct = (metric) => {
|
||||
const m = total[metric];
|
||||
if (!m) return "-";
|
||||
return `${m.pct.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const rows = [
|
||||
"| Metric | Coverage |",
|
||||
"| ------ | -------- |",
|
||||
`| Statements | ${pct("statements")} |`,
|
||||
`| Branches | ${pct("branches")} |`,
|
||||
`| Functions | ${pct("functions")} |`,
|
||||
`| Lines | ${pct("lines")} |`,
|
||||
];
|
||||
|
||||
const md = `## Coverage\n\n${rows.join("\n")}\n`;
|
||||
appendFileSync(GITHUB_STEP_SUMMARY, md + "\n");
|
||||
console.log("Coverage summary written to job summary.");
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Probes whether mutating a mid-history message destroys DeepSeek's prompt
|
||||
* cache for everything after the mutation point.
|
||||
*
|
||||
* Hypothesis: our compactInPlace() rewrites old tool results, which shifts
|
||||
* the byte offsets of every subsequent message. DeepSeek caches by exact
|
||||
* prefix, so the next request would cache-hit only up to the mutation
|
||||
* point, even though most of the conversation is unchanged.
|
||||
*
|
||||
* Run: node scripts/probe-cache.mjs
|
||||
* Reads DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from .env.testbak.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function loadDotenv(path) {
|
||||
const txt = readFileSync(path, "utf8");
|
||||
for (const line of txt.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) process.env[m[1]] ??= m[2];
|
||||
}
|
||||
}
|
||||
loadDotenv("./.env.testbak");
|
||||
|
||||
const KEY = process.env.DEEPSEEK_API_KEY;
|
||||
const BASE = process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com";
|
||||
if (!KEY) throw new Error("DEEPSEEK_API_KEY missing");
|
||||
|
||||
const filler = (label, n) =>
|
||||
Array.from({ length: n }, (_, i) => `${label} line ${i}: lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`).join("\n");
|
||||
|
||||
const SYS = "You are a terse echo bot. Reply with a single short sentence.";
|
||||
const MSG_A_FULL = `Long context block A. Detail follows:\n${filler("A", 80)}`;
|
||||
const MSG_A_TRUNCATED = `Long context block A. Detail follows:\n${filler("A", 8)}\n[truncated]`;
|
||||
const MSG_B = `Block B reference: ${filler("B", 30)}`;
|
||||
const MSG_C = `Block C reference: ${filler("C", 30)}`;
|
||||
|
||||
async function call(label, messages) {
|
||||
const t0 = Date.now();
|
||||
const res = await fetch(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
messages,
|
||||
temperature: 0,
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
const ms = Date.now() - t0;
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${label} http ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
const j = await res.json();
|
||||
const u = j.usage ?? {};
|
||||
const hit = u.prompt_cache_hit_tokens ?? 0;
|
||||
const miss = u.prompt_cache_miss_tokens ?? 0;
|
||||
const prompt = u.prompt_tokens ?? 0;
|
||||
const ratio = hit + miss > 0 ? (hit / (hit + miss)) * 100 : 0;
|
||||
console.log(
|
||||
`[${label}] prompt=${prompt} hit=${hit} miss=${miss} hit%=${ratio.toFixed(1)} ${ms}ms`,
|
||||
);
|
||||
return { hit, miss, prompt };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
console.log("=== Phase 1: warm cache with full conversation ===");
|
||||
const warm = [
|
||||
{ role: "system", content: SYS },
|
||||
{ role: "user", content: MSG_A_FULL },
|
||||
{ role: "assistant", content: "ack A" },
|
||||
{ role: "user", content: MSG_B },
|
||||
{ role: "assistant", content: "ack B" },
|
||||
{ role: "user", content: MSG_C },
|
||||
{ role: "assistant", content: "ack C" },
|
||||
{ role: "user", content: "say hi" },
|
||||
];
|
||||
await call("warm-1", warm);
|
||||
await sleep(1500);
|
||||
await call("warm-2", warm);
|
||||
|
||||
console.log("\n=== Phase 2: append a new turn (cache should hit on prefix) ===");
|
||||
const appended = [
|
||||
...warm,
|
||||
{ role: "assistant", content: "hi" },
|
||||
{ role: "user", content: "say bye" },
|
||||
];
|
||||
await sleep(1500);
|
||||
const appendResult = await call("append", appended);
|
||||
|
||||
console.log("\n=== Phase 3: mutate MSG_A in place + append same new turn ===");
|
||||
const mutated = [
|
||||
{ role: "system", content: SYS },
|
||||
{ role: "user", content: MSG_A_TRUNCATED },
|
||||
{ role: "assistant", content: "ack A" },
|
||||
{ role: "user", content: MSG_B },
|
||||
{ role: "assistant", content: "ack B" },
|
||||
{ role: "user", content: MSG_C },
|
||||
{ role: "assistant", content: "ack C" },
|
||||
{ role: "user", content: "say hi" },
|
||||
{ role: "assistant", content: "hi" },
|
||||
{ role: "user", content: "say bye" },
|
||||
];
|
||||
await sleep(1500);
|
||||
const mutateResult = await call("mutate", mutated);
|
||||
|
||||
console.log("\n=== Result (Phases 1-3) ===");
|
||||
const lostHit = appendResult.hit - mutateResult.hit;
|
||||
console.log(
|
||||
`append: hit=${appendResult.hit}/${appendResult.prompt} mutate: hit=${mutateResult.hit}/${mutateResult.prompt}`,
|
||||
);
|
||||
console.log(`cache tokens lost to in-place mutation: ${lostHit}`);
|
||||
if (lostHit > 100) {
|
||||
console.log("VERDICT: in-place mutation destroys cache. Hypothesis confirmed.");
|
||||
} else {
|
||||
console.log("VERDICT: cache survived mutation. Hypothesis rejected.");
|
||||
}
|
||||
|
||||
console.log("\n=== Phase 4: append-only across many turns (the new code's behavior) ===");
|
||||
const session = [
|
||||
{ role: "system", content: SYS },
|
||||
{ role: "user", content: MSG_A_FULL },
|
||||
{ role: "assistant", content: "ack A" },
|
||||
{ role: "user", content: MSG_B },
|
||||
{ role: "assistant", content: "ack B" },
|
||||
];
|
||||
const ratios = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
session.push({ role: "user", content: `turn ${i}: ${filler(`Q${i}`, 10)}` });
|
||||
await sleep(1200);
|
||||
const r = await call(`turn-${i}`, session);
|
||||
session.push({ role: "assistant", content: `reply ${i}` });
|
||||
const total = r.hit + r.miss;
|
||||
ratios.push(total > 0 ? (r.hit / total) * 100 : 0);
|
||||
}
|
||||
const avg = ratios.reduce((a, b) => a + b, 0) / ratios.length;
|
||||
console.log(`\nappend-only cache hit % across 5 turns: ${ratios.map((x) => x.toFixed(1)).join(", ")}`);
|
||||
console.log(`average: ${avg.toFixed(1)}%`);
|
||||
if (avg > 80) {
|
||||
console.log("VERDICT: append-only keeps cache warm across turns. New strategy validated.");
|
||||
} else {
|
||||
console.log("VERDICT: cache hit lower than expected — investigate.");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Long-session probe — drives CacheFirstLoop through 20 real turns
|
||||
* with oversized tool results (each ~4k tokens, the size that USED to
|
||||
* trigger the old turn-end auto-compaction every turn).
|
||||
*
|
||||
* Reports per-turn: prompt size, cache hit %, miss tokens, USD cost.
|
||||
* Surfaces: cache trajectory, cost shape, anything degrading over time.
|
||||
*
|
||||
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-long-session.mts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { ImmutablePrefix } from "../src/memory/runtime.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS } from "../src/telemetry/stats.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
|
||||
// Force a small ctx window so the 50% fold threshold trips in a few
|
||||
// turns instead of needing 200+ turns at the real 1M cap. Same model
|
||||
// id, real API call, just the local gauge is shrunk.
|
||||
DEEPSEEK_CONTEXT_TOKENS["deepseek-chat"] = 50_000;
|
||||
|
||||
function loadDotenv(path: string) {
|
||||
const txt = readFileSync(path, "utf8");
|
||||
for (const line of txt.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
loadDotenv("./.env.testbak");
|
||||
|
||||
const PRICE_HIT_PER_M = 0.07;
|
||||
const PRICE_MISS_PER_M = 0.27;
|
||||
const PRICE_COMPLETION_PER_M = 1.1;
|
||||
|
||||
const docLine = (i: number, sec: string) =>
|
||||
`[${sec}#${i}] section ${sec} entry ${i}: requirement traces to constraint ${(i % 7) + 1}, status ${i % 3 === 0 ? "open" : "closed"}, owner team-${(i % 5) + 1}, last touched 2026-04-${(i % 28) + 1}.`;
|
||||
|
||||
async function main() {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({
|
||||
name: "read_doc",
|
||||
description: "Read a section of a project document.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { section: { type: "string" } },
|
||||
required: ["section"],
|
||||
},
|
||||
fn: async (args: Record<string, unknown>) => {
|
||||
const sec = String(args.section ?? "default");
|
||||
const lines = Array.from({ length: 65 }, (_, i) => docLine(i, sec));
|
||||
return lines.join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
const client = new DeepSeekClient();
|
||||
const model = "deepseek-chat";
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({
|
||||
system:
|
||||
"You are a documentation triage agent. For each turn, call read_doc with the section the user asks about, then reply with one short sentence summarizing what you found.",
|
||||
toolSpecs: reg.specs(),
|
||||
}),
|
||||
tools: reg,
|
||||
stream: false,
|
||||
model,
|
||||
maxToolIters: 4,
|
||||
});
|
||||
|
||||
let mutations = 0;
|
||||
let folds = 0;
|
||||
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
|
||||
loop.log.compactInPlace = (...args) => {
|
||||
mutations++;
|
||||
return origCompactInPlace(...args);
|
||||
};
|
||||
|
||||
const sections = [
|
||||
"auth",
|
||||
"billing",
|
||||
"telemetry",
|
||||
"rate-limit",
|
||||
"webhooks",
|
||||
"search",
|
||||
"indexing",
|
||||
"permissions",
|
||||
"audit",
|
||||
"exports",
|
||||
"imports",
|
||||
"cdn",
|
||||
"analytics",
|
||||
"rbac",
|
||||
"sso",
|
||||
"scheduler",
|
||||
"workflows",
|
||||
"notifications",
|
||||
"reports",
|
||||
"api-v2",
|
||||
];
|
||||
|
||||
console.log("turn | prompt | hit | miss | hit% | $/turn | $cum");
|
||||
console.log("-----+----------+---------+---------+------+----------+--------");
|
||||
|
||||
let cumCost = 0;
|
||||
let forceSummaryHit = false;
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const t0 = Date.now();
|
||||
let usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
promptCacheHitTokens: number;
|
||||
promptCacheMissTokens: number;
|
||||
} | null = null;
|
||||
let warning = "";
|
||||
for await (const ev of loop.step(`Read the "${sections[i]}" section.`)) {
|
||||
if (ev.role === "assistant_final" && ev.stats?.usage) {
|
||||
usage = ev.stats.usage as typeof usage;
|
||||
}
|
||||
if (ev.role === "warning" && ev.content) {
|
||||
warning = ev.content;
|
||||
if (/folded \d+ messages/.test(ev.content)) folds++;
|
||||
}
|
||||
if (ev.forcedSummary) forceSummaryHit = true;
|
||||
}
|
||||
const ms = Date.now() - t0;
|
||||
if (!usage) {
|
||||
console.log(
|
||||
`${String(i).padStart(3)} | (no usage) -- ${warning ? `warning: ${warning}` : ""}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
|
||||
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
|
||||
const cost =
|
||||
(usage.promptCacheHitTokens * PRICE_HIT_PER_M +
|
||||
usage.promptCacheMissTokens * PRICE_MISS_PER_M +
|
||||
usage.completionTokens * PRICE_COMPLETION_PER_M) /
|
||||
1_000_000;
|
||||
cumCost += cost;
|
||||
console.log(
|
||||
`${String(i).padStart(3)} | ${String(usage.promptTokens).padStart(7)} | ${String(usage.promptCacheHitTokens).padStart(7)} | ${String(usage.promptCacheMissTokens).padStart(7)} | ${ratio.toFixed(1).padStart(4)} | $${cost.toFixed(5)} | $${cumCost.toFixed(4)} ${ms}ms${warning ? ` (${warning.slice(0, 60)}…)` : ""}`,
|
||||
);
|
||||
if (forceSummaryHit) {
|
||||
console.log(
|
||||
`\n>> force-summary triggered at turn ${i} (${ratio.toFixed(1)}% cache hit, ${usage.promptTokens} tokens)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\ntotal log.compactInPlace() calls: ${mutations} (expected: ${folds} folds)`);
|
||||
console.log(`total cost across session: $${cumCost.toFixed(4)}`);
|
||||
|
||||
if (mutations !== folds) {
|
||||
console.log(`FAIL: ${mutations} mutations but only ${folds} fold events — unexpected mutation`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nVERDICT: ${folds} fold(s) fired, no other mutations.`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* End-to-end cache probe — drives CacheFirstLoop through real turns
|
||||
* against the live DeepSeek API and reports cache hit % per turn.
|
||||
*
|
||||
* The point: validate that the post-PR code (no auto-compaction)
|
||||
* actually sustains high cache hit on a long-ish session, not just
|
||||
* that the API-level append-vs-mutate primitive behaves as expected.
|
||||
*
|
||||
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-loop-cache.mts
|
||||
* Reads DEEPSEEK_API_KEY from .env.testbak.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { ImmutablePrefix } from "../src/memory/runtime.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
|
||||
function loadDotenv(path: string) {
|
||||
const txt = readFileSync(path, "utf8");
|
||||
for (const line of txt.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
loadDotenv("./.env.testbak");
|
||||
|
||||
const filler = (label: string, n: number): string =>
|
||||
Array.from(
|
||||
{ length: n },
|
||||
(_, i) =>
|
||||
`${label} line ${i}: lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||
).join("\n");
|
||||
|
||||
async function main() {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({
|
||||
name: "echo",
|
||||
description: "echo the input back",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { msg: { type: "string" } },
|
||||
required: ["msg"],
|
||||
},
|
||||
fn: async (args: Record<string, unknown>) => `echoed: ${String(args.msg ?? "")}`,
|
||||
});
|
||||
|
||||
const client = new DeepSeekClient();
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({
|
||||
system:
|
||||
"You are a terse echo bot. Reply with one short sentence. Do not call any tools unless explicitly asked.",
|
||||
toolSpecs: reg.specs(),
|
||||
}),
|
||||
tools: reg,
|
||||
stream: false,
|
||||
maxToolIters: 4,
|
||||
});
|
||||
|
||||
// Pre-seed log with a moderate prior conversation (~6k tokens of
|
||||
// user/assistant turns) so the cache has something substantial to
|
||||
// hit across subsequent turns.
|
||||
loop.log.append({ role: "user", content: `prior context: ${filler("ctx", 60)}` });
|
||||
loop.log.append({ role: "assistant", content: "noted." });
|
||||
loop.log.append({ role: "user", content: `more context: ${filler("more", 40)}` });
|
||||
loop.log.append({ role: "assistant", content: "noted." });
|
||||
|
||||
const ratios: number[] = [];
|
||||
let mutations = 0;
|
||||
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
|
||||
loop.log.compactInPlace = (...args: Parameters<typeof origCompactInPlace>) => {
|
||||
mutations++;
|
||||
return origCompactInPlace(...args);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let usage: { promptTokens: number; promptCacheHitTokens: number; promptCacheMissTokens: number } | null = null;
|
||||
for await (const ev of loop.step(`Turn ${i}: just say "ok ${i}".`)) {
|
||||
if (ev.role === "assistant_final" && ev.stats?.usage) {
|
||||
usage = ev.stats.usage as typeof usage;
|
||||
}
|
||||
}
|
||||
if (!usage) {
|
||||
console.log(`turn-${i}: no usage captured`);
|
||||
ratios.push(0);
|
||||
continue;
|
||||
}
|
||||
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
|
||||
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
|
||||
console.log(
|
||||
`turn-${i}: prompt=${usage.promptTokens} hit=${usage.promptCacheHitTokens} miss=${usage.promptCacheMissTokens} hit%=${ratio.toFixed(1)}`,
|
||||
);
|
||||
ratios.push(ratio);
|
||||
}
|
||||
|
||||
console.log(`\ntotal log.compactInPlace() calls: ${mutations}`);
|
||||
console.log(`cache hit % per turn: ${ratios.map((x) => x.toFixed(1)).join(", ")}`);
|
||||
|
||||
const warmRatios = ratios.slice(1);
|
||||
const avgWarm = warmRatios.reduce((a, b) => a + b, 0) / warmRatios.length;
|
||||
console.log(`warm-turn average (excluding cold start): ${avgWarm.toFixed(1)}%`);
|
||||
|
||||
if (mutations > 0) {
|
||||
console.log(`\nFAIL: log was mutated ${mutations} time(s) — append-only invariant broken`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (avgWarm < 80) {
|
||||
console.log(`\nFAIL: warm-turn average ${avgWarm.toFixed(1)}% below 80% threshold`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nPASS: append-only sustained, cache hit avg ${avgWarm.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +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";
|
||||
@@ -23,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";
|
||||
@@ -35,6 +44,221 @@ export interface ProgressInfo {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface SpecRecord {
|
||||
spec: string;
|
||||
client: McpClient;
|
||||
summary: McpServerSummary;
|
||||
/** Names of bridged tools — used for hot-unbridge. */
|
||||
registeredNames: string[];
|
||||
/** ToolSpec snapshots captured AFTER bridge — handed to loop.prefix.addTool on hot-add. */
|
||||
registeredSpecs: import("../../types.js").ToolSpec[];
|
||||
}
|
||||
|
||||
interface RuntimeContext {
|
||||
getTools: () => ToolRegistry | undefined;
|
||||
getMcpPrefix: () => string | undefined;
|
||||
getRequestedCount: () => number;
|
||||
progressSink: { current: ((info: ProgressInfo) => void) | null };
|
||||
}
|
||||
|
||||
export interface McpRuntime {
|
||||
size(): number;
|
||||
specs(): string[];
|
||||
summaries(): McpServerSummary[];
|
||||
addSpec(
|
||||
raw: string,
|
||||
loop?: CacheFirstLoop,
|
||||
): Promise<{ ok: true; summary: McpServerSummary } | { ok: false; reason: string }>;
|
||||
removeSpec(raw: string, loop?: CacheFirstLoop): Promise<boolean>;
|
||||
reloadFromConfig(loop?: CacheFirstLoop): Promise<{
|
||||
added: string[];
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
summaries: McpServerSummary[];
|
||||
}>;
|
||||
closeAll(): Promise<void>;
|
||||
}
|
||||
|
||||
function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
|
||||
const records = new Map<string, SpecRecord>();
|
||||
const insertionOrder: string[] = [];
|
||||
|
||||
async function addSpec(
|
||||
raw: string,
|
||||
loop?: CacheFirstLoop,
|
||||
): Promise<{ ok: true; summary: McpServerSummary } | { ok: false; reason: string }> {
|
||||
if (records.has(raw)) {
|
||||
return { ok: true, summary: records.get(raw)!.summary };
|
||||
}
|
||||
const tools = ctx.getTools();
|
||||
if (!tools) return { ok: false, reason: "no tool registry available" };
|
||||
const disabledNames = new Set(readConfig().mcpDisabled ?? []);
|
||||
let label = "anon";
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
const spec = parseMcpSpec(raw);
|
||||
label = spec.name ?? "anon";
|
||||
if (spec.name && disabledNames.has(spec.name)) {
|
||||
process.stderr.write(`${formatMcpLifecycleEvent({ state: "disabled", name: label })}\n`);
|
||||
return { ok: false, reason: "disabled by user" };
|
||||
}
|
||||
process.stderr.write(`${formatMcpLifecycleEvent({ state: "handshake", name: label })}\n`);
|
||||
const t0 = Date.now();
|
||||
const namePrefix = spec.name
|
||||
? `${spec.name}_`
|
||||
: 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 })
|
||||
: spec.transport === "streamable-http"
|
||||
? new StreamableHttpTransport({ url: spec.url })
|
||||
: new StdioTransport({ command: spec.command, args: spec.args });
|
||||
mcp = new McpClient({ transport });
|
||||
await mcp.initialize();
|
||||
const host: McpClientHost = { client: mcp };
|
||||
const bridge = await bridgeMcpTools(mcp, {
|
||||
registry: tools,
|
||||
namePrefix,
|
||||
serverName: label,
|
||||
host,
|
||||
onProgress: (info) => ctx.progressSink.current?.(info),
|
||||
onSlow: (info) =>
|
||||
process.stderr.write(
|
||||
`${formatMcpSlowToast({ name: info.serverName, p95Ms: info.p95Ms, sampleSize: info.sampleSize })}\n`,
|
||||
),
|
||||
});
|
||||
let report: InspectionReport;
|
||||
try {
|
||||
report = await inspectMcpServer(mcp);
|
||||
} catch {
|
||||
report = {
|
||||
protocolVersion: mcp.protocolVersion,
|
||||
serverInfo: mcp.serverInfo,
|
||||
capabilities: mcp.serverCapabilities ?? {},
|
||||
tools: { supported: true, items: [] },
|
||||
resources: { supported: false, reason: "inspect failed" },
|
||||
prompts: { supported: false, reason: "inspect failed" },
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
const ms = Date.now() - t0;
|
||||
const resourceCount = report.resources.supported ? report.resources.items.length : 0;
|
||||
const promptCount = report.prompts.supported ? report.prompts.items.length : 0;
|
||||
process.stderr.write(
|
||||
`${formatMcpLifecycleEvent({
|
||||
state: "connected",
|
||||
name: label,
|
||||
tools: bridge.registeredNames.length,
|
||||
resources: resourceCount,
|
||||
prompts: promptCount,
|
||||
ms,
|
||||
})}\n`,
|
||||
);
|
||||
const summary = buildMcpServerSummary({
|
||||
label,
|
||||
spec: raw,
|
||||
toolCount: bridge.registeredNames.length,
|
||||
report,
|
||||
host,
|
||||
bridgeEnv: bridge.env,
|
||||
});
|
||||
// Snapshot tool specs AFTER bridge so hot-add can replay them into loop.prefix.
|
||||
const allSpecs = tools.specs();
|
||||
const registeredSpecs = allSpecs.filter((s) =>
|
||||
bridge.registeredNames.includes(s.function.name),
|
||||
);
|
||||
records.set(raw, {
|
||||
spec: raw,
|
||||
client: mcp,
|
||||
summary,
|
||||
registeredNames: bridge.registeredNames,
|
||||
registeredSpecs,
|
||||
});
|
||||
insertionOrder.push(raw);
|
||||
// Hot-add: shift the prefix so the live loop sees the new tools
|
||||
// on the very next turn. Each addTool is one cache-miss turn.
|
||||
if (loop) for (const s of registeredSpecs) loop.prefix.addTool(s);
|
||||
return { ok: true, summary };
|
||||
} catch (err) {
|
||||
await mcp?.close().catch(() => undefined);
|
||||
const reason = (err as Error).message;
|
||||
process.stderr.write(
|
||||
`${formatMcpLifecycleEvent({ state: "failed", name: label, reason })}\n → run \`reasonix setup\` to remove this entry, or fix the underlying issue (missing npm package, network, etc.).\n`,
|
||||
);
|
||||
return { ok: false, reason };
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSpec(raw: string, loop?: CacheFirstLoop): Promise<boolean> {
|
||||
const record = records.get(raw);
|
||||
if (!record) return false;
|
||||
await record.client.close().catch(() => undefined);
|
||||
const tools = ctx.getTools();
|
||||
for (const name of record.registeredNames) {
|
||||
tools?.unregister(name);
|
||||
loop?.prefix.removeTool(name);
|
||||
}
|
||||
records.delete(raw);
|
||||
const idx = insertionOrder.indexOf(raw);
|
||||
if (idx >= 0) insertionOrder.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reloadFromConfig(loop?: CacheFirstLoop): Promise<{
|
||||
added: string[];
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
summaries: McpServerSummary[];
|
||||
}> {
|
||||
const desired = readConfig().mcp ?? [];
|
||||
const desiredSet = new Set(desired);
|
||||
const currentSet = new Set(records.keys());
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const failed: Array<{ spec: string; reason: string }> = [];
|
||||
|
||||
for (const spec of [...currentSet]) {
|
||||
if (!desiredSet.has(spec)) {
|
||||
await removeSpec(spec, loop);
|
||||
removed.push(spec);
|
||||
}
|
||||
}
|
||||
for (const spec of desired) {
|
||||
if (currentSet.has(spec)) continue;
|
||||
const result = await addSpec(spec, loop);
|
||||
if (result.ok) added.push(spec);
|
||||
else failed.push({ spec, reason: result.reason });
|
||||
}
|
||||
return { added, removed, failed, summaries: summaries() };
|
||||
}
|
||||
|
||||
function specs(): string[] {
|
||||
return [...insertionOrder];
|
||||
}
|
||||
function summaries(): McpServerSummary[] {
|
||||
return insertionOrder
|
||||
.map((s) => records.get(s)?.summary)
|
||||
.filter((s): s is McpServerSummary => Boolean(s));
|
||||
}
|
||||
async function closeAll(): Promise<void> {
|
||||
for (const r of records.values()) await r.client.close().catch(() => undefined);
|
||||
records.clear();
|
||||
insertionOrder.length = 0;
|
||||
}
|
||||
return {
|
||||
size: () => records.size,
|
||||
specs,
|
||||
summaries,
|
||||
addSpec,
|
||||
removeSpec,
|
||||
reloadFromConfig,
|
||||
closeAll,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
model: string;
|
||||
system: string;
|
||||
@@ -85,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 {
|
||||
@@ -96,6 +327,8 @@ interface RootProps extends ChatOptions {
|
||||
progressSink: { current: ((info: ProgressInfo) => void) | null };
|
||||
/** Show the SessionPicker (full list) when no --session was specified and saved sessions exist. */
|
||||
showPicker: boolean;
|
||||
/** Hot-reload runtime — passed through to App so /mcp browse + dashboard can bridge after install. */
|
||||
mcpRuntime: McpRuntime;
|
||||
}
|
||||
|
||||
function Root({
|
||||
@@ -105,6 +338,7 @@ function Root({
|
||||
mcpServers,
|
||||
progressSink,
|
||||
showPicker,
|
||||
mcpRuntime,
|
||||
...appProps
|
||||
}: RootProps) {
|
||||
const [key, setKey] = useState<string | undefined>(initialKey);
|
||||
@@ -176,6 +410,7 @@ function Root({
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
mcpRuntime={mcpRuntime}
|
||||
progressSink={progressSink}
|
||||
codeMode={appProps.codeMode}
|
||||
noDashboard={appProps.noDashboard}
|
||||
@@ -190,10 +425,6 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
const initialKey = loadApiKey();
|
||||
|
||||
const requestedSpecs = opts.mcp ?? [];
|
||||
const clients: McpClient[] = [];
|
||||
const successfulSpecs: string[] = [];
|
||||
const failedSpecs: Array<{ spec: string; reason: string }> = [];
|
||||
const mcpServers: McpServerSummary[] = [];
|
||||
// Shared progress sink: the bridge's onProgress callback writes
|
||||
// through `progressSink.current`, which App.tsx sets to its UI
|
||||
// updater on mount. Started null so early progress frames (before
|
||||
@@ -204,122 +435,37 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
// replacing. When no seed AND no MCP, tools stays undefined and
|
||||
// the loop runs as a bare chat.
|
||||
let tools: ToolRegistry | undefined = opts.seedTools;
|
||||
if (requestedSpecs.length > 0 && !tools) tools = new ToolRegistry();
|
||||
|
||||
const disabledNames = new Set(readConfig().mcpDisabled ?? []);
|
||||
if (requestedSpecs.length > 0) {
|
||||
if (!tools) tools = new ToolRegistry();
|
||||
for (const raw of requestedSpecs) {
|
||||
let label = "anon";
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
const spec = parseMcpSpec(raw);
|
||||
label = spec.name ?? "anon";
|
||||
if (spec.name && disabledNames.has(spec.name)) {
|
||||
process.stderr.write(`${formatMcpLifecycleEvent({ state: "disabled", name: label })}\n`);
|
||||
continue;
|
||||
}
|
||||
process.stderr.write(`${formatMcpLifecycleEvent({ state: "handshake", name: label })}\n`);
|
||||
const t0 = Date.now();
|
||||
const prefix = spec.name
|
||||
? `${spec.name}_`
|
||||
: requestedSpecs.length === 1 && opts.mcpPrefix
|
||||
? opts.mcpPrefix
|
||||
: "";
|
||||
const transport: McpTransport =
|
||||
spec.transport === "sse"
|
||||
? new SseTransport({ url: spec.url })
|
||||
: spec.transport === "streamable-http"
|
||||
? new StreamableHttpTransport({ url: spec.url })
|
||||
: new StdioTransport({ command: spec.command, args: spec.args });
|
||||
mcp = new McpClient({ transport });
|
||||
await mcp.initialize();
|
||||
// Host indirection lets `/mcp reconnect` swap the underlying client
|
||||
// without re-bridging the registered tools (closures resolve via
|
||||
// host.client at call time).
|
||||
const host: McpClientHost = { client: mcp };
|
||||
const bridge = await bridgeMcpTools(mcp, {
|
||||
registry: tools,
|
||||
namePrefix: prefix,
|
||||
serverName: label,
|
||||
host,
|
||||
onProgress: (info) => progressSink.current?.(info),
|
||||
onSlow: (info) =>
|
||||
process.stderr.write(
|
||||
`${formatMcpSlowToast({ name: info.serverName, p95Ms: info.p95Ms, sampleSize: info.sampleSize })}\n`,
|
||||
),
|
||||
});
|
||||
// Inspect collects resources + prompts once so `/mcp` can render them
|
||||
// synchronously. Servers that don't support these fall through as
|
||||
// `{supported: false}` instead of throwing.
|
||||
let report: InspectionReport;
|
||||
try {
|
||||
report = await inspectMcpServer(mcp);
|
||||
} catch {
|
||||
report = {
|
||||
protocolVersion: mcp.protocolVersion,
|
||||
serverInfo: mcp.serverInfo,
|
||||
capabilities: mcp.serverCapabilities ?? {},
|
||||
tools: { supported: true, items: [] },
|
||||
resources: { supported: false, reason: "inspect failed" },
|
||||
prompts: { supported: false, reason: "inspect failed" },
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
const ms = Date.now() - t0;
|
||||
const resourceCount = report.resources.supported ? report.resources.items.length : 0;
|
||||
const promptCount = report.prompts.supported ? report.prompts.items.length : 0;
|
||||
process.stderr.write(
|
||||
`${formatMcpLifecycleEvent({
|
||||
state: "connected",
|
||||
name: label,
|
||||
tools: bridge.registeredNames.length,
|
||||
resources: resourceCount,
|
||||
prompts: promptCount,
|
||||
ms,
|
||||
})}\n`,
|
||||
);
|
||||
clients.push(mcp);
|
||||
successfulSpecs.push(raw);
|
||||
mcpServers.push(
|
||||
buildMcpServerSummary({
|
||||
label,
|
||||
spec: raw,
|
||||
toolCount: bridge.registeredNames.length,
|
||||
report,
|
||||
host,
|
||||
bridgeEnv: bridge.env,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
// Per-server failure is non-fatal: one broken server shouldn't
|
||||
// kill a chat that has working servers configured. Recover by
|
||||
// recording the failure and continuing; user fixes via `reasonix
|
||||
// setup` without losing their other servers.
|
||||
await mcp?.close().catch(() => undefined);
|
||||
const reason = (err as Error).message;
|
||||
failedSpecs.push({ spec: raw, reason });
|
||||
process.stderr.write(
|
||||
`${formatMcpLifecycleEvent({ state: "failed", name: label, reason })}\n → run \`reasonix setup\` to remove this entry, or fix the underlying issue (missing npm package, network, etc.).\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// If every requested server failed AND no seed registry was
|
||||
// provided, drop the empty registry so the loop still runs as a
|
||||
// bare chat instead of advertising zero tools. If the caller
|
||||
// passed seedTools we keep the registry — the seed tools are
|
||||
// still there and usable.
|
||||
if (successfulSpecs.length === 0 && !opts.seedTools) {
|
||||
tools = undefined;
|
||||
}
|
||||
const runtime = createMcpRuntime({
|
||||
getTools: () => tools,
|
||||
getMcpPrefix: () => opts.mcpPrefix,
|
||||
getRequestedCount: () => requestedSpecs.length,
|
||||
progressSink,
|
||||
});
|
||||
|
||||
const failedSpecs: Array<{ spec: string; reason: string }> = [];
|
||||
for (const raw of requestedSpecs) {
|
||||
const result = await runtime.addSpec(raw);
|
||||
if (!result.ok) failedSpecs.push({ spec: raw, reason: result.reason });
|
||||
}
|
||||
const mcpSpecs = successfulSpecs;
|
||||
if (runtime.size() === 0 && !opts.seedTools) {
|
||||
tools = undefined;
|
||||
}
|
||||
// Preserve configured specs even when startup leaves them unbridged
|
||||
// so `/mcp` can still surface the configured-but-offline set.
|
||||
const mcpSpecs = [...requestedSpecs];
|
||||
const mcpServers = runtime.summaries();
|
||||
|
||||
// Register web search/fetch tools unless explicitly disabled. DDG
|
||||
// backs them with no key required; the model invokes them whenever
|
||||
// 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.
|
||||
@@ -350,34 +496,35 @@ 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}
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
mcpRuntime={runtime}
|
||||
progressSink={progressSink}
|
||||
showPicker={showPicker}
|
||||
{...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 {
|
||||
for (const c of clients) await c.close();
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -226,7 +226,7 @@ async function checkSessions(): Promise<Check> {
|
||||
return {
|
||||
label: "sessions ",
|
||||
level: "warn",
|
||||
detail: `${detail} · ${stale} idle ≥90d (run /prune-sessions)`,
|
||||
detail: `${detail} · ${stale} idle ≥90d (run \`reasonix prune-sessions\`)`,
|
||||
};
|
||||
}
|
||||
return { label: "sessions ", level: "ok", detail };
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/** `reasonix mcp browse` — Ink TUI for the MCP marketplace. Lazy-loads pages on scroll. */
|
||||
|
||||
import { Box, Text, render, useApp, useInput } from "ink";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import {
|
||||
type RegistryHandle,
|
||||
loadMorePages,
|
||||
openRegistry,
|
||||
specStringFor,
|
||||
} from "../../mcp/registry-fetch.js";
|
||||
import type { RegistryEntry } from "../../mcp/registry-types.js";
|
||||
|
||||
const VISIBLE_ROWS = 12;
|
||||
|
||||
interface State {
|
||||
handle: RegistryHandle | null;
|
||||
loading: boolean;
|
||||
query: string;
|
||||
selected: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function rankAndFilter(entries: RegistryEntry[], query: string): RegistryEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q
|
||||
? entries.filter((e) => `${e.name} ${e.title} ${e.description}`.toLowerCase().includes(q))
|
||||
: entries;
|
||||
return [...list].sort((a, b) => {
|
||||
const ap = a.popularity ?? -1;
|
||||
const bp = b.popularity ?? -1;
|
||||
if (ap !== bp) return bp - ap;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function McpBrowseApp() {
|
||||
const app = useApp();
|
||||
const [state, setState] = useState<State>({
|
||||
handle: null,
|
||||
loading: true,
|
||||
query: "",
|
||||
selected: 0,
|
||||
status: "opening registry…",
|
||||
});
|
||||
|
||||
const setStatus = useCallback((status: string) => {
|
||||
setState((s) => ({ ...s, status }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const handle = await openRegistry({});
|
||||
if (cancelled) return;
|
||||
const ageMs = Date.now() - handle.fetchedAt;
|
||||
const ageStr =
|
||||
ageMs < 60_000 ? `${Math.floor(ageMs / 1000)}s` : `${Math.floor(ageMs / 60_000)}m`;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
handle,
|
||||
loading: false,
|
||||
status: `${handle.source} · ${handle.cache.entries.length} entries${
|
||||
handle.fromCache ? ` · cached ${ageStr} ago` : ""
|
||||
}`,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setState((s) => ({ ...s, loading: false, status: `error: ${(err as Error).message}` }));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!state.handle) return [];
|
||||
return rankAndFilter(state.handle.cache.entries, state.query);
|
||||
}, [state.handle, state.query]);
|
||||
|
||||
const selected = filtered[state.selected];
|
||||
|
||||
const fetchMore = useCallback(async () => {
|
||||
if (!state.handle || state.loading) return;
|
||||
if (state.handle.cache.pagination.nextCursor === null) {
|
||||
setStatus("no more pages — registry exhausted");
|
||||
return;
|
||||
}
|
||||
setState((s) => ({ ...s, loading: true, status: "loading more…" }));
|
||||
try {
|
||||
const r = await loadMorePages(state.handle, { pages: 5 });
|
||||
setState((s) => ({
|
||||
...s,
|
||||
loading: false,
|
||||
status: `+${r.newEntries} entries (${state.handle?.cache.entries.length ?? 0} total)${
|
||||
r.exhausted ? " · exhausted" : ""
|
||||
}`,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, status: `error: ${(err as Error).message}` }));
|
||||
}
|
||||
}, [state.handle, state.loading, setStatus]);
|
||||
|
||||
const install = useCallback(
|
||||
(entry: RegistryEntry) => {
|
||||
if (!entry.install) {
|
||||
setStatus(`${entry.name} has no install info (smithery listing)`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const spec = specStringFor(entry.name, entry.install);
|
||||
const cfg = readConfig();
|
||||
const existing = cfg.mcp ?? [];
|
||||
if (existing.includes(spec)) {
|
||||
setStatus(`already installed: ${spec}`);
|
||||
return;
|
||||
}
|
||||
writeConfig({ ...cfg, mcp: [...existing, spec] });
|
||||
setStatus(`installed → ${spec}`);
|
||||
} catch (err) {
|
||||
setStatus(`install failed: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
[setStatus],
|
||||
);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape || (key.ctrl && input === "c")) {
|
||||
app.exit();
|
||||
return;
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setState((s) => ({ ...s, selected: Math.max(0, s.selected - 1) }));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setState((s) => ({ ...s, selected: Math.min(filtered.length - 1, s.selected + 1) }));
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
if (selected) install(selected);
|
||||
return;
|
||||
}
|
||||
if (key.tab || (key.ctrl && input === "n")) {
|
||||
void fetchMore();
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setState((s) => ({ ...s, query: s.query.slice(0, -1), selected: 0 }));
|
||||
return;
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta) {
|
||||
setState((s) => ({ ...s, query: s.query + input, selected: 0 }));
|
||||
}
|
||||
});
|
||||
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(state.selected - Math.floor(VISIBLE_ROWS / 2), filtered.length - VISIBLE_ROWS),
|
||||
);
|
||||
const window = filtered.slice(Math.max(0, start), Math.max(0, start) + VISIBLE_ROWS);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
◈ MCP marketplace
|
||||
</Text>
|
||||
<Text dimColor>{` · ${state.status}`}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>search: </Text>
|
||||
<Text color="white">{state.query || "(type to filter)"}</Text>
|
||||
<Text dimColor>{` ${filtered.length} match${filtered.length === 1 ? "" : "es"}`}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{window.length === 0 ? (
|
||||
<Text dimColor>{state.loading ? "loading…" : "no entries"}</Text>
|
||||
) : (
|
||||
window.map((e, i) => {
|
||||
const idx = (start || 0) + i;
|
||||
const active = idx === state.selected;
|
||||
const tag =
|
||||
e.source === "official" ? "[off]" : e.source === "smithery" ? "[smt]" : "[loc]";
|
||||
const pop = e.popularity !== undefined ? ` · ${e.popularity.toLocaleString()}` : "";
|
||||
return (
|
||||
<Box key={e.name}>
|
||||
<Text color={active ? "cyan" : undefined}>{active ? "▸ " : " "}</Text>
|
||||
<Text bold={active}>{e.name.padEnd(40).slice(0, 40)}</Text>
|
||||
<Text dimColor>{` ${tag}${pop}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
{selected ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold color="white">
|
||||
{selected.title}
|
||||
</Text>
|
||||
{selected.description ? <Text dimColor>{selected.description.slice(0, 160)}</Text> : null}
|
||||
{selected.install ? (
|
||||
<Text dimColor>
|
||||
{`spec: ${selected.install.runtime} ${selected.install.packageId ?? selected.install.url ?? "—"} · ${selected.install.transport}`}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>(smithery listing — install info not exposed)</Text>
|
||||
)}
|
||||
{selected.install?.requiredEnv?.length ? (
|
||||
<Text color="yellow">{`needs: ${selected.install.requiredEnv.join(", ")}`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>type to filter · ↑↓ pick · enter install · tab load more · esc quit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface McpBrowseOptions {
|
||||
/** Reserved — currently unused, kept for symmetry with other commands. */
|
||||
_unused?: never;
|
||||
}
|
||||
|
||||
export async function mcpBrowseCommand(_opts: McpBrowseOptions = {}): Promise<void> {
|
||||
loadDotenv();
|
||||
const { waitUntilExit } = render(<McpBrowseApp />, {
|
||||
exitOnCtrlC: true,
|
||||
patchConsole: false,
|
||||
});
|
||||
await waitUntilExit();
|
||||
}
|
||||
@@ -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}]`);
|
||||
|
||||
@@ -1,33 +1,315 @@
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import { MCP_CATALOG, mcpCommandFor } from "../../mcp/catalog.js";
|
||||
import {
|
||||
type FetchProgress,
|
||||
fetchSmitheryDetail,
|
||||
handleToFetchResult,
|
||||
loadMorePages,
|
||||
openRegistry,
|
||||
specStringFor,
|
||||
} from "../../mcp/registry-fetch.js";
|
||||
import type { RegistryEntry } from "../../mcp/registry-types.js";
|
||||
|
||||
export interface McpListOptions {
|
||||
/** Emit JSON on stdout instead of the human-readable table. */
|
||||
json?: boolean;
|
||||
const DEFAULT_LIST_LIMIT = 30;
|
||||
/** Soft cap on how far `search` walks the registry on first run. */
|
||||
const SEARCH_PAGE_CAP = 20;
|
||||
/** Soft cap on how far `install` walks looking for a name. */
|
||||
const INSTALL_PAGE_CAP = 30;
|
||||
|
||||
const progressToStderr: FetchProgress = ({ source, page, entries }) => {
|
||||
if (page === 1 || page % 5 === 0) {
|
||||
process.stderr.write(`\r▸ fetching ${source} registry · page ${page} · ${entries} entries`);
|
||||
}
|
||||
};
|
||||
|
||||
function finishProgressLine(): void {
|
||||
if (process.stderr.isTTY) process.stderr.write("\r\x1b[K");
|
||||
else process.stderr.write("\n");
|
||||
}
|
||||
|
||||
export function mcpListCommand(opts: McpListOptions): void {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(MCP_CATALOG, null, 2));
|
||||
return;
|
||||
}
|
||||
export interface McpListOptions {
|
||||
json?: boolean;
|
||||
/** Skip network — only show the bundled MCP_CATALOG entries. */
|
||||
local?: boolean;
|
||||
/** Bypass cache TTL. */
|
||||
refresh?: boolean;
|
||||
/** How many entries to show. Default 30. */
|
||||
limit?: number;
|
||||
/** Eagerly load this many pages before showing. Default 1. */
|
||||
pages?: number;
|
||||
/** Walk all pages of the registry (slow on first run). */
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
console.log("Popular MCP servers you can bridge into Reasonix:");
|
||||
console.log("");
|
||||
for (const entry of MCP_CATALOG) {
|
||||
console.log(` ${pad(entry.name, 12)} ${entry.summary}`);
|
||||
console.log(` ${mcpCommandFor(entry)}`);
|
||||
if (entry.note) console.log(` · ${entry.note}`);
|
||||
console.log("");
|
||||
}
|
||||
console.log("Usage: reasonix chat <one-of-the---mcp-lines-above>");
|
||||
console.log(
|
||||
"Docs: https://github.com/modelcontextprotocol/servers — Anthropic's official server repo",
|
||||
);
|
||||
console.log(
|
||||
" https://mcp.so — community-maintained catalog",
|
||||
);
|
||||
export interface McpSearchOptions {
|
||||
json?: boolean;
|
||||
refresh?: boolean;
|
||||
limit?: number;
|
||||
/** Cap how many pages to walk while searching. Default 20. */
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
export interface McpInstallOptions {
|
||||
refresh?: boolean;
|
||||
/** Cap how many pages to walk while looking for the name. Default 30. */
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
function rankEntries(entries: RegistryEntry[]): RegistryEntry[] {
|
||||
return [...entries].sort((a, b) => {
|
||||
const ap = a.popularity ?? -1;
|
||||
const bp = b.popularity ?? -1;
|
||||
if (ap !== bp) return bp - ap;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function pad(s: string, width: number): string {
|
||||
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
||||
}
|
||||
|
||||
function fmtAge(ms: number): string {
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
return `${Math.floor(sec / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function printEntry(e: RegistryEntry, indent = " "): void {
|
||||
const tag =
|
||||
e.source === "official" ? "[official]" : e.source === "smithery" ? "[smithery]" : "[local]";
|
||||
const pop = e.popularity !== undefined ? ` · ${e.popularity.toLocaleString()} uses` : "";
|
||||
console.log(`${indent}${pad(e.name, 36)} ${tag}${pop}`);
|
||||
if (e.description) console.log(`${indent} ${e.description}`);
|
||||
if (e.install?.requiredEnv?.length) {
|
||||
console.log(`${indent} needs: ${e.install.requiredEnv.join(", ")}`);
|
||||
} else if (!e.install) {
|
||||
console.log(`${indent} (smithery listing — install detail fetched lazily on install)`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mcpListCommand(opts: McpListOptions = {}): Promise<void> {
|
||||
if (opts.local) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(MCP_CATALOG, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log("Bundled MCP servers (offline catalog):");
|
||||
console.log("");
|
||||
for (const entry of MCP_CATALOG) {
|
||||
console.log(` ${pad(entry.name, 12)} ${entry.summary}`);
|
||||
console.log(` ${mcpCommandFor(entry)}`);
|
||||
if (entry.note) console.log(` · ${entry.note}`);
|
||||
console.log("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = await openRegistry({ noCache: opts.refresh, onProgress: progressToStderr });
|
||||
const wantedPages = opts.all ? Number.POSITIVE_INFINITY : (opts.pages ?? 1);
|
||||
const additional = Math.max(0, wantedPages - handle.cache.pagination.pagesLoaded);
|
||||
if (additional > 0) {
|
||||
await loadMorePages(handle, {
|
||||
pages: additional,
|
||||
onProgress: progressToStderr,
|
||||
});
|
||||
}
|
||||
finishProgressLine();
|
||||
|
||||
const result = handleToFetchResult(handle);
|
||||
const ranked = rankEntries(result.entries);
|
||||
const limit = opts.limit ?? DEFAULT_LIST_LIMIT;
|
||||
const shown = ranked.slice(0, limit);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
source: result.source,
|
||||
fromCache: result.fromCache,
|
||||
fetchedAt: result.fetchedAt,
|
||||
loaded: result.entries.length,
|
||||
hasMore: result.hasMore,
|
||||
entries: shown,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const ageStr = result.fromCache
|
||||
? `cached, ${fmtAge(Date.now() - result.fetchedAt)}`
|
||||
: "just fetched";
|
||||
const moreStr = result.hasMore ? "more available" : "all loaded";
|
||||
console.log(
|
||||
`MCP servers from ${result.source} registry (${result.entries.length} loaded, ${moreStr}, ${ageStr}):`,
|
||||
);
|
||||
if (result.errors.length > 0) {
|
||||
for (const e of result.errors) console.error(` warn: ${e}`);
|
||||
}
|
||||
console.log("");
|
||||
for (const e of shown) printEntry(e);
|
||||
if (ranked.length > limit) {
|
||||
console.log(
|
||||
` … ${ranked.length - limit} more loaded — use \`reasonix mcp search <query>\` to filter`,
|
||||
);
|
||||
}
|
||||
if (result.hasMore) {
|
||||
console.log(" ▸ more pages available — `reasonix mcp list --pages <n>` or --all");
|
||||
}
|
||||
console.log("");
|
||||
console.log("Install: reasonix mcp install <name>");
|
||||
}
|
||||
|
||||
function matchFilter(query: string): (e: RegistryEntry) => boolean {
|
||||
const q = query.toLowerCase();
|
||||
return (e) => `${e.name} ${e.title} ${e.description}`.toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
export async function mcpSearchCommand(query: string, opts: McpSearchOptions = {}): Promise<void> {
|
||||
const q = query.trim();
|
||||
if (!q) {
|
||||
console.error("usage: reasonix mcp search <query>");
|
||||
process.exit(1);
|
||||
}
|
||||
const handle = await openRegistry({ noCache: opts.refresh, onProgress: progressToStderr });
|
||||
const filter = matchFilter(q);
|
||||
const limit = opts.limit ?? DEFAULT_LIST_LIMIT;
|
||||
const cap = opts.maxPages ?? SEARCH_PAGE_CAP;
|
||||
|
||||
await loadMorePages(handle, {
|
||||
pages: Math.max(0, cap - handle.cache.pagination.pagesLoaded),
|
||||
matchTarget: limit,
|
||||
filter,
|
||||
onProgress: progressToStderr,
|
||||
});
|
||||
finishProgressLine();
|
||||
|
||||
const result = handleToFetchResult(handle);
|
||||
const matches = rankEntries(result.entries.filter(filter));
|
||||
const shown = matches.slice(0, limit);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
query: q,
|
||||
source: result.source,
|
||||
loaded: result.entries.length,
|
||||
hasMore: result.hasMore,
|
||||
matches: matches.length,
|
||||
entries: shown,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shown.length === 0) {
|
||||
console.log(
|
||||
`No matches for "${q}" across ${result.entries.length} loaded entries (${result.source}${
|
||||
result.hasMore ? ", more pages exist — try --refresh or `mcp list --all`" : ""
|
||||
}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`${matches.length} match(es) for "${q}" in ${result.source} registry (${result.entries.length} entries scanned):`,
|
||||
);
|
||||
console.log("");
|
||||
for (const e of shown) printEntry(e);
|
||||
if (matches.length > limit) console.log(` … ${matches.length - limit} more matches`);
|
||||
}
|
||||
|
||||
function findEntry(entries: RegistryEntry[], name: string): RegistryEntry | null {
|
||||
const exact = entries.find((e) => e.name === name);
|
||||
if (exact) return exact;
|
||||
const lower = name.toLowerCase();
|
||||
const ci = entries.find((e) => e.name.toLowerCase() === lower);
|
||||
if (ci) return ci;
|
||||
const tail = entries.find((e) => e.name.toLowerCase().endsWith(`/${lower}`));
|
||||
if (tail) return tail;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function mcpInstallCommand(name: string, opts: McpInstallOptions = {}): Promise<void> {
|
||||
const target = name.trim();
|
||||
if (!target) {
|
||||
console.error("usage: reasonix mcp install <name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const handle = await openRegistry({ noCache: opts.refresh, onProgress: progressToStderr });
|
||||
const lower = target.toLowerCase();
|
||||
const filter = (e: RegistryEntry): boolean => {
|
||||
const n = e.name.toLowerCase();
|
||||
return n === lower || n.endsWith(`/${lower}`) || n.includes(lower);
|
||||
};
|
||||
const cap = opts.maxPages ?? INSTALL_PAGE_CAP;
|
||||
|
||||
await loadMorePages(handle, {
|
||||
pages: Math.max(0, cap - handle.cache.pagination.pagesLoaded),
|
||||
matchTarget: 1,
|
||||
filter,
|
||||
onProgress: progressToStderr,
|
||||
});
|
||||
finishProgressLine();
|
||||
|
||||
const entry = findEntry(handle.cache.entries, target);
|
||||
if (!entry) {
|
||||
console.error(
|
||||
`No MCP server named "${target}" found after walking ${handle.cache.pagination.pagesLoaded} page(s) of the ${handle.source} registry.`,
|
||||
);
|
||||
if (handle.cache.pagination.nextCursor !== null) {
|
||||
console.error(`Try: reasonix mcp install ${target} --max-pages 100`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!entry.install && entry.source === "smithery") {
|
||||
process.stderr.write(`▸ fetching smithery install detail for ${entry.name}…\n`);
|
||||
const fetched = await fetchSmitheryDetail(entry.name);
|
||||
if (fetched) entry.install = fetched;
|
||||
}
|
||||
|
||||
if (!entry.install) {
|
||||
console.error(
|
||||
`Could not derive install metadata for "${entry.name}" — try \`npx -y @smithery/cli install ${entry.name}\` directly.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let spec: string;
|
||||
try {
|
||||
spec = specStringFor(entry.name, entry.install);
|
||||
} catch (err) {
|
||||
console.error(`Cannot build install spec for ${entry.name}: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cfg = readConfig();
|
||||
const existing = cfg.mcp ?? [];
|
||||
if (existing.includes(spec)) {
|
||||
console.log(`Already installed: ${spec}`);
|
||||
return;
|
||||
}
|
||||
const next = { ...cfg, mcp: [...existing, spec] };
|
||||
writeConfig(next);
|
||||
|
||||
console.log(`Installed: ${entry.name}`);
|
||||
console.log(` spec: ${spec}`);
|
||||
if (entry.install.requiredEnv?.length) {
|
||||
console.log(
|
||||
` needs: ${entry.install.requiredEnv.join(", ")} (set these in your env before next chat)`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
console.log(
|
||||
"Use it: reasonix chat (or `reasonix code`) — the server will be bridged automatically.",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { listSessions, pruneStaleSessions } from "../../memory/session.js";
|
||||
|
||||
export interface PruneSessionsOptions {
|
||||
days?: number;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export function pruneSessionsCommand(opts: PruneSessionsOptions): void {
|
||||
const days = opts.days ?? 90;
|
||||
if (!Number.isFinite(days) || days < 1) {
|
||||
console.error(`--days must be a positive integer (got ${days}).`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const stale = listSessions().filter((s) => s.mtime.getTime() < cutoff);
|
||||
if (stale.length === 0) {
|
||||
console.log(`no sessions idle ≥${days} days. Nothing would be pruned.`);
|
||||
return;
|
||||
}
|
||||
console.log(`would prune ${stale.length} session(s) idle ≥${days} days:`);
|
||||
for (const s of stale) {
|
||||
console.log(` ${s.name}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log("re-run without --dry-run to actually delete.");
|
||||
return;
|
||||
}
|
||||
const removed = pruneStaleSessions(days);
|
||||
if (removed.length === 0) {
|
||||
console.log(`no sessions idle ≥${days} days. Nothing pruned.`);
|
||||
return;
|
||||
}
|
||||
console.log(`pruned ${removed.length} session(s) idle ≥${days} days:`);
|
||||
for (const name of removed) {
|
||||
console.log(` ${name}`);
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -34,8 +34,10 @@ import { diffCommand } from "./commands/diff.js";
|
||||
import { doctorCommand } from "./commands/doctor.js";
|
||||
import { eventsCommand } from "./commands/events.js";
|
||||
import { indexCommand } from "./commands/index.js";
|
||||
import { mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { mcpListCommand } from "./commands/mcp.js";
|
||||
import { mcpBrowseCommand } from "./commands/mcp-browse.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";
|
||||
import { runCommand } from "./commands/run.js";
|
||||
import { sessionsCommand } from "./commands/sessions.js";
|
||||
@@ -111,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) => {
|
||||
@@ -126,6 +129,7 @@ program
|
||||
noDashboard: opts.dashboard === false,
|
||||
systemAppend: opts.systemAppend,
|
||||
systemAppendFile: opts.systemAppendFile,
|
||||
altScreen: opts.altScreen !== false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,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,
|
||||
@@ -188,6 +193,7 @@ program
|
||||
forceResume: continueOpts.forceResume,
|
||||
forceNew: !!opts.new,
|
||||
noDashboard: opts.dashboard === false,
|
||||
altScreen: opts.altScreen !== false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,6 +268,15 @@ program
|
||||
sessionsCommand({ name, verbose: !!opts.verbose });
|
||||
});
|
||||
|
||||
program
|
||||
.command("prune-sessions")
|
||||
.description(t("cli.pruneSessions"))
|
||||
.option("--days <n>", t("ui.pruneDaysHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--dry-run", t("ui.pruneDryRunHint"))
|
||||
.action((opts) => {
|
||||
pruneSessionsCommand({ days: opts.days, dryRun: !!opts.dryRun });
|
||||
});
|
||||
|
||||
program
|
||||
.command("events <name>")
|
||||
.description(t("cli.events"))
|
||||
@@ -322,8 +337,77 @@ mcp
|
||||
.command("list")
|
||||
.description(t("ui.mcpListDescription"))
|
||||
.option("--json", t("ui.jsonHintCatalog"))
|
||||
.action((opts) => {
|
||||
mcpListCommand({ json: !!opts.json });
|
||||
.option("--local", t("ui.mcpLocalHint"))
|
||||
.option("--refresh", t("ui.mcpRefreshHint"))
|
||||
.option("--limit <n>", t("ui.mcpLimitHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--pages <n>", t("ui.mcpPagesHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--all", t("ui.mcpAllHint"))
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await mcpListCommand({
|
||||
json: !!opts.json,
|
||||
local: !!opts.local,
|
||||
refresh: !!opts.refresh,
|
||||
limit: typeof opts.limit === "number" && opts.limit > 0 ? opts.limit : undefined,
|
||||
pages: typeof opts.pages === "number" && opts.pages > 0 ? opts.pages : undefined,
|
||||
all: !!opts.all,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp list failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("search <query>")
|
||||
.description(t("ui.mcpSearchDescription"))
|
||||
.option("--json", t("ui.jsonHintCatalog"))
|
||||
.option("--refresh", t("ui.mcpRefreshHint"))
|
||||
.option("--limit <n>", t("ui.mcpLimitHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--max-pages <n>", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10))
|
||||
.action(async (query: string, opts) => {
|
||||
try {
|
||||
await mcpSearchCommand(query, {
|
||||
json: !!opts.json,
|
||||
refresh: !!opts.refresh,
|
||||
limit: typeof opts.limit === "number" && opts.limit > 0 ? opts.limit : undefined,
|
||||
maxPages:
|
||||
typeof opts.maxPages === "number" && opts.maxPages > 0 ? opts.maxPages : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp search failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("install <name>")
|
||||
.description(t("ui.mcpInstallDescription"))
|
||||
.option("--refresh", t("ui.mcpRefreshHint"))
|
||||
.option("--max-pages <n>", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10))
|
||||
.action(async (name: string, opts) => {
|
||||
try {
|
||||
await mcpInstallCommand(name, {
|
||||
refresh: !!opts.refresh,
|
||||
maxPages:
|
||||
typeof opts.maxPages === "number" && opts.maxPages > 0 ? opts.maxPages : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp install failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("browse")
|
||||
.description(t("ui.mcpBrowseDescription"))
|
||||
.action(async () => {
|
||||
try {
|
||||
await mcpBrowseCommand();
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp browse failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
mcp
|
||||
@@ -334,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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useMemo, useState } from "react";
|
||||
import type { CheckpointMeta } from "../../code/checkpoints.js";
|
||||
import { fmtAgo } from "../../code/checkpoints.js";
|
||||
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type CheckpointPickerOutcome =
|
||||
| { kind: "restore"; id: string }
|
||||
| { kind: "delete"; id: string }
|
||||
| { kind: "quit" };
|
||||
|
||||
export interface CheckpointPickerProps {
|
||||
checkpoints: ReadonlyArray<CheckpointMeta>;
|
||||
workspace: string;
|
||||
onChoose: (outcome: CheckpointPickerOutcome) => void;
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 6;
|
||||
|
||||
export function CheckpointPicker({
|
||||
checkpoints,
|
||||
workspace,
|
||||
onChoose,
|
||||
pickerPorts,
|
||||
}: CheckpointPickerProps): React.ReactElement {
|
||||
const [focus, setFocus] = useState(0);
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
|
||||
|
||||
const snapshot = useMemo(
|
||||
() => ({
|
||||
pickerKind: "checkpoints",
|
||||
title: `restore a checkpoint — ${workspace}`,
|
||||
items: checkpoints.map((c) => {
|
||||
const sizeKb = (c.bytes / 1024).toFixed(1);
|
||||
const tag = c.source === "manual" ? "" : ` (${c.source})`;
|
||||
return {
|
||||
id: c.id,
|
||||
title: `${c.name}${tag}`,
|
||||
subtitle: `${c.fileCount} file${c.fileCount === 1 ? "" : "s"} · ${sizeKb} KB`,
|
||||
badge: c.id.slice(0, 7),
|
||||
meta: fmtAgo(c.createdAt),
|
||||
};
|
||||
}),
|
||||
actions: ["pick", "delete", "cancel"] as const,
|
||||
hint: "↑↓ pick · ⏎ restore · [d] forget · esc quit",
|
||||
}),
|
||||
[checkpoints, workspace],
|
||||
);
|
||||
|
||||
usePickerBroadcast(
|
||||
!!pickerPorts,
|
||||
{
|
||||
...snapshot,
|
||||
actions: [...snapshot.actions],
|
||||
},
|
||||
(res) => {
|
||||
if (res.action === "pick") return onChoose({ kind: "restore", id: res.id });
|
||||
if (res.action === "delete") return onChoose({ kind: "delete", id: res.id });
|
||||
if (res.action === "cancel") return onChoose({ kind: "quit" });
|
||||
},
|
||||
pickerPorts ?? {
|
||||
broadcast: () => undefined,
|
||||
resolverRef: { current: null },
|
||||
snapshotRef: { current: null },
|
||||
},
|
||||
);
|
||||
|
||||
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(checkpoints.length - 1, f + 1));
|
||||
if (checkpoints.length === 0) {
|
||||
if (ev.return) return onChoose({ kind: "quit" });
|
||||
return;
|
||||
}
|
||||
const target = checkpoints[focus];
|
||||
if (!target) return;
|
||||
if (ev.return) return onChoose({ kind: "restore", id: target.id });
|
||||
if (ev.input === "q") return onChoose({ kind: "quit" });
|
||||
if (ev.input === "d") return onChoose({ kind: "delete", id: target.id });
|
||||
});
|
||||
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(focus - Math.floor(visibleCount / 2), checkpoints.length - visibleCount),
|
||||
);
|
||||
const end = Math.min(checkpoints.length, start + visibleCount);
|
||||
const shown = checkpoints.slice(start, end);
|
||||
const hiddenBelow = checkpoints.length - end;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text bold color={TONE.brand}>
|
||||
{" ◈ REASONIX · pick a checkpoint "}
|
||||
</Text>
|
||||
<Text color={FG.meta}>{` · ${workspace}`}</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
{checkpoints.length === 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>
|
||||
{" no checkpoints in this workspace yet — see /checkpoint to make one"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
shown.map((c, i) => <CheckpointRow key={c.id} info={c} focused={start + i === focus} />)
|
||||
)}
|
||||
{hiddenBelow > 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>
|
||||
{checkpoints.length === 0
|
||||
? " esc quit"
|
||||
: " ↑↓ pick · ⏎ restore · [d] forget · esc quit"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckpointRow({
|
||||
info,
|
||||
focused,
|
||||
}: {
|
||||
info: CheckpointMeta;
|
||||
focused: boolean;
|
||||
}): React.ReactElement {
|
||||
const tag = info.source === "manual" ? "" : ` (${info.source})`;
|
||||
const sizeKb = (info.bytes / 1024).toFixed(1);
|
||||
const time = fmtAgo(info.createdAt);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
|
||||
<Text color={FG.meta}>{info.id.slice(0, 7).padEnd(8)}</Text>
|
||||
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
|
||||
{info.name}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{tag}</Text>
|
||||
<Box flexGrow={1} />
|
||||
<Text color={FG.faint}>{`${time.padStart(8)} · `}</Text>
|
||||
<Text
|
||||
color={FG.faint}
|
||||
>{`${info.fileCount} file${info.fileCount === 1 ? "" : "s"}, ${sizeKb} KB`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/** `/mcp` slash modal — single hub with two tabs: Live (attached servers) + Marketplace (registry). */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { McpBrowser } from "./McpBrowser.js";
|
||||
import { McpMarketplace } from "./McpMarketplace.js";
|
||||
import type { PickerBroadcastPorts } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import type { ApplyAppend } from "./mcp-reconnect-kickoff.js";
|
||||
import type { McpServerSummary } from "./slash/types.js";
|
||||
import { COLOR } from "./theme.js";
|
||||
|
||||
export type McpHubTab = "live" | "marketplace";
|
||||
|
||||
export interface McpHubProps {
|
||||
initialTab: McpHubTab;
|
||||
liveServers: McpServerSummary[];
|
||||
configPath: string;
|
||||
onClose: () => void;
|
||||
postInfo: (text: string) => void;
|
||||
applyAppend?: ApplyAppend;
|
||||
reloadMcp?: () => Promise<{
|
||||
added: string[];
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
}>;
|
||||
/** Forwarded to the marketplace tab so the web dashboard can drive install / uninstall / refine / load-more. */
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
export function McpHub({
|
||||
initialTab,
|
||||
liveServers,
|
||||
configPath,
|
||||
onClose,
|
||||
postInfo,
|
||||
applyAppend,
|
||||
reloadMcp,
|
||||
pickerPorts,
|
||||
}: McpHubProps) {
|
||||
const [tab, setTab] = useState<McpHubTab>(initialTab);
|
||||
|
||||
// Hub-level: Tab key cycles tabs. Inner components don't bind Tab
|
||||
// (Marketplace rebound to PgDn for load-more) so no conflict.
|
||||
useKeystroke((ev) => {
|
||||
if (ev.paste) return;
|
||||
if (ev.tab) setTab((t) => (t === "live" ? "marketplace" : "live"));
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1}>
|
||||
<Text bold color={COLOR.brand}>
|
||||
◈ MCP
|
||||
</Text>
|
||||
<Text>{" "}</Text>
|
||||
<TabPill label="Live" count={liveServers.length} active={tab === "live"} />
|
||||
<Text>{" "}</Text>
|
||||
<TabPill label="Marketplace" active={tab === "marketplace"} />
|
||||
<Text dimColor>{" tab to switch"}</Text>
|
||||
</Box>
|
||||
{tab === "live" ? (
|
||||
<McpBrowser
|
||||
servers={liveServers}
|
||||
configPath={configPath}
|
||||
onClose={onClose}
|
||||
postInfo={postInfo}
|
||||
applyAppend={applyAppend}
|
||||
/>
|
||||
) : (
|
||||
<McpMarketplace
|
||||
onClose={onClose}
|
||||
postInfo={postInfo}
|
||||
reloadMcp={reloadMcp}
|
||||
pickerPorts={pickerPorts}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TabPill({ label, count, active }: { label: string; count?: number; active: boolean }) {
|
||||
const text = count !== undefined ? `${label} (${count})` : label;
|
||||
if (active) {
|
||||
return (
|
||||
<Text bold color={COLOR.brand}>
|
||||
[{text}]
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return <Text dimColor>{` ${text} `}</Text>;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/** `/mcp browse` modal — registry marketplace inside the chat session. */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import {
|
||||
type RegistryHandle,
|
||||
fetchSmitheryDetail,
|
||||
loadMorePages,
|
||||
openRegistry,
|
||||
specStringFor,
|
||||
} from "../../mcp/registry-fetch.js";
|
||||
import type { RegistryEntry } from "../../mcp/registry-types.js";
|
||||
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { COLOR } from "./theme.js";
|
||||
|
||||
const VISIBLE_ROWS = 10;
|
||||
|
||||
export interface McpMarketplaceProps {
|
||||
onClose: () => void;
|
||||
/** Pushed back into the chat scrollback after install/uninstall. */
|
||||
postInfo: (text: string) => void;
|
||||
/** Optional hot-reload — present in chat session, absent in standalone CLI use. */
|
||||
reloadMcp?: () => Promise<{
|
||||
added: string[];
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
}>;
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
interface State {
|
||||
handle: RegistryHandle | null;
|
||||
loading: boolean;
|
||||
query: string;
|
||||
selected: number;
|
||||
status: string;
|
||||
/** specs currently in config.mcp[] — refreshed after install/uninstall. */
|
||||
installedSpecs: string[];
|
||||
}
|
||||
|
||||
export function buildMarketplacePickerSnapshot(args: {
|
||||
filtered: RegistryEntry[];
|
||||
installedSpecs: string[];
|
||||
query: string;
|
||||
status: string;
|
||||
hasMore: boolean;
|
||||
}) {
|
||||
return {
|
||||
pickerKind: "mcp-marketplace" as const,
|
||||
title: `MCP marketplace · ${args.status}`,
|
||||
query: args.query,
|
||||
items: args.filtered.map((e) => {
|
||||
const installedSpec = isInstalled(args.installedSpecs, e);
|
||||
return {
|
||||
id: e.name,
|
||||
title: e.title || e.name,
|
||||
subtitle: e.description?.slice(0, 200) ?? undefined,
|
||||
badge: installedSpec
|
||||
? "installed"
|
||||
: e.source === "official"
|
||||
? "official"
|
||||
: e.source === "smithery"
|
||||
? "smithery"
|
||||
: "local",
|
||||
meta: e.popularity !== undefined ? `★ ${e.popularity.toLocaleString()}` : undefined,
|
||||
};
|
||||
}),
|
||||
actions: ["install", "uninstall", "refine", "load-more", "cancel"] as const,
|
||||
hasMore: args.hasMore,
|
||||
hint: "type filter · ↑↓ pick · ⏎ install/toggle · PgDn load more · esc close",
|
||||
};
|
||||
}
|
||||
|
||||
function rankAndFilter(entries: RegistryEntry[], query: string): RegistryEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q
|
||||
? entries.filter((e) => `${e.name} ${e.title} ${e.description}`.toLowerCase().includes(q))
|
||||
: entries;
|
||||
return [...list].sort((a, b) => {
|
||||
const ap = a.popularity ?? -1;
|
||||
const bp = b.popularity ?? -1;
|
||||
if (ap !== bp) return bp - ap;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function readInstalledSpecs(): string[] {
|
||||
return readConfig().mcp ?? [];
|
||||
}
|
||||
|
||||
function isInstalled(installedSpecs: string[], entry: RegistryEntry): string | null {
|
||||
if (!entry.install) return null;
|
||||
try {
|
||||
const spec = specStringFor(entry.name, entry.install);
|
||||
return installedSpecs.includes(spec) ? spec : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function McpMarketplace({ onClose, postInfo, reloadMcp, pickerPorts }: McpMarketplaceProps) {
|
||||
const [state, setState] = useState<State>({
|
||||
handle: null,
|
||||
loading: true,
|
||||
query: "",
|
||||
selected: 0,
|
||||
status: "opening registry…",
|
||||
installedSpecs: readInstalledSpecs(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const handle = await openRegistry({});
|
||||
if (cancelled) return;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
handle,
|
||||
loading: false,
|
||||
status: `${handle.source} · ${handle.cache.entries.length} entries${
|
||||
handle.fromCache ? " · cached" : ""
|
||||
}`,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setState((s) => ({ ...s, loading: false, status: `error: ${(err as Error).message}` }));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!state.handle) return [];
|
||||
return rankAndFilter(state.handle.cache.entries, state.query);
|
||||
}, [state.handle, state.query]);
|
||||
|
||||
const selected = filtered[state.selected];
|
||||
|
||||
const fetchMore = useCallback(async () => {
|
||||
if (!state.handle || state.loading) return;
|
||||
if (state.handle.cache.pagination.nextCursor === null) {
|
||||
setState((s) => ({ ...s, status: "all pages loaded" }));
|
||||
return;
|
||||
}
|
||||
setState((s) => ({ ...s, loading: true, status: "loading more…" }));
|
||||
try {
|
||||
const r = await loadMorePages(state.handle, { pages: 5 });
|
||||
setState((s) => ({
|
||||
...s,
|
||||
loading: false,
|
||||
status: `+${r.newEntries} · ${state.handle?.cache.entries.length ?? 0} total${
|
||||
r.exhausted ? " · exhausted" : ""
|
||||
}`,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, status: `error: ${(err as Error).message}` }));
|
||||
}
|
||||
}, [state.handle, state.loading]);
|
||||
|
||||
const doUninstall = useCallback(
|
||||
async (entry: RegistryEntry, installed: string) => {
|
||||
const cfg = readConfig();
|
||||
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
|
||||
writeConfig({ ...cfg, mcp: next });
|
||||
setState((s) => ({ ...s, installedSpecs: next, status: `uninstalled ${entry.name}` }));
|
||||
if (reloadMcp) {
|
||||
try {
|
||||
await reloadMcp();
|
||||
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
|
||||
} catch (err) {
|
||||
postInfo(
|
||||
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
|
||||
}
|
||||
},
|
||||
[postInfo, reloadMcp],
|
||||
);
|
||||
|
||||
const doInstall = useCallback(
|
||||
async (entry: RegistryEntry) => {
|
||||
let install = entry.install;
|
||||
if (!install && entry.source === "smithery") {
|
||||
setState((s) => ({ ...s, loading: true, status: "fetching smithery detail…" }));
|
||||
try {
|
||||
const detail = await fetchSmitheryDetail(entry.name);
|
||||
if (detail) {
|
||||
install = detail;
|
||||
entry.install = detail;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to error below */
|
||||
}
|
||||
setState((s) => ({ ...s, loading: false }));
|
||||
}
|
||||
if (!install) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
status: `no install info for ${entry.name} — try \`npx -y @smithery/cli install ${entry.name}\``,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const spec = specStringFor(entry.name, install);
|
||||
const cfg = readConfig();
|
||||
const existing = cfg.mcp ?? [];
|
||||
if (existing.includes(spec)) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
installedSpecs: existing,
|
||||
status: `already installed: ${spec}`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const next = [...existing, spec];
|
||||
writeConfig({ ...cfg, mcp: next });
|
||||
setState((s) => ({ ...s, installedSpecs: next, status: `installed → ${spec}` }));
|
||||
const envHint = install.requiredEnv?.length
|
||||
? ` · needs env: ${install.requiredEnv.join(", ")}`
|
||||
: "";
|
||||
if (reloadMcp) {
|
||||
try {
|
||||
const r = await reloadMcp();
|
||||
const failedHere = r.failed.find((f) => f.spec === spec);
|
||||
if (failedHere) {
|
||||
postInfo(`▲ installed ${entry.name} — bridge failed: ${failedHere.reason}${envHint}`);
|
||||
} else {
|
||||
postInfo(`✓ installed ${entry.name} — bridged${envHint}`);
|
||||
}
|
||||
} catch (err) {
|
||||
postInfo(
|
||||
`✓ installed ${entry.name} — restart \`reasonix code\` to bridge (reload failed: ${(err as Error).message})${envHint}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
postInfo(`✓ installed ${entry.name} — restart \`reasonix code\` to bridge${envHint}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, status: `install failed: ${(err as Error).message}` }));
|
||||
}
|
||||
},
|
||||
[postInfo, reloadMcp],
|
||||
);
|
||||
|
||||
const installOrToggle = useCallback(
|
||||
async (entry: RegistryEntry) => {
|
||||
const installed = isInstalled(state.installedSpecs, entry);
|
||||
if (installed) await doUninstall(entry, installed);
|
||||
else await doInstall(entry);
|
||||
},
|
||||
[state.installedSpecs, doInstall, doUninstall],
|
||||
);
|
||||
|
||||
const pickerSnapshot = useMemo(
|
||||
() =>
|
||||
buildMarketplacePickerSnapshot({
|
||||
filtered,
|
||||
installedSpecs: state.installedSpecs,
|
||||
query: state.query,
|
||||
status: state.status,
|
||||
hasMore: state.handle?.cache.pagination.nextCursor != null,
|
||||
}),
|
||||
[filtered, state.installedSpecs, state.handle, state.query, state.status],
|
||||
);
|
||||
|
||||
usePickerBroadcast(
|
||||
!!pickerPorts,
|
||||
{ ...pickerSnapshot, actions: [...pickerSnapshot.actions] },
|
||||
(res) => {
|
||||
if (res.action === "cancel") return onClose();
|
||||
if (res.action === "refine") {
|
||||
setState((s) => ({ ...s, query: res.query, selected: 0 }));
|
||||
return;
|
||||
}
|
||||
if (res.action === "load-more") {
|
||||
void fetchMore();
|
||||
return;
|
||||
}
|
||||
if (res.action === "install") {
|
||||
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
|
||||
if (!entry) return;
|
||||
if (isInstalled(state.installedSpecs, entry)) {
|
||||
setState((s) => ({ ...s, status: `already installed: ${entry.name}` }));
|
||||
return;
|
||||
}
|
||||
void doInstall(entry);
|
||||
return;
|
||||
}
|
||||
if (res.action === "uninstall") {
|
||||
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
|
||||
if (!entry) return;
|
||||
const installed = isInstalled(state.installedSpecs, entry);
|
||||
if (!installed) {
|
||||
setState((s) => ({ ...s, status: `not installed: ${entry.name}` }));
|
||||
return;
|
||||
}
|
||||
void doUninstall(entry, installed);
|
||||
}
|
||||
},
|
||||
pickerPorts ?? {
|
||||
broadcast: () => undefined,
|
||||
resolverRef: { current: null },
|
||||
snapshotRef: { current: null },
|
||||
},
|
||||
);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.paste) return;
|
||||
if (ev.escape) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (ev.upArrow) {
|
||||
setState((s) => ({ ...s, selected: Math.max(0, s.selected - 1) }));
|
||||
return;
|
||||
}
|
||||
if (ev.downArrow) {
|
||||
setState((s) => ({ ...s, selected: Math.min(filtered.length - 1, s.selected + 1) }));
|
||||
return;
|
||||
}
|
||||
if (ev.return) {
|
||||
if (selected) void installOrToggle(selected);
|
||||
return;
|
||||
}
|
||||
if (ev.pageDown) {
|
||||
void fetchMore();
|
||||
return;
|
||||
}
|
||||
if (ev.backspace || ev.delete) {
|
||||
setState((s) => ({ ...s, query: s.query.slice(0, -1), selected: 0 }));
|
||||
return;
|
||||
}
|
||||
if (ev.input && !ev.ctrl && !ev.meta) {
|
||||
setState((s) => ({ ...s, query: s.query + ev.input, selected: 0 }));
|
||||
}
|
||||
});
|
||||
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(state.selected - Math.floor(VISIBLE_ROWS / 2), filtered.length - VISIBLE_ROWS),
|
||||
);
|
||||
const window = filtered.slice(Math.max(0, start), Math.max(0, start) + VISIBLE_ROWS);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box>
|
||||
<Text bold color={COLOR.brand}>
|
||||
◈ MCP marketplace
|
||||
</Text>
|
||||
<Text dimColor>{` · ${state.status}`}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>filter: </Text>
|
||||
<Text>{state.query || "(type to filter)"}</Text>
|
||||
<Text dimColor>{` ${filtered.length} match${filtered.length === 1 ? "" : "es"}`}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{window.length === 0 ? (
|
||||
<Text dimColor>{state.loading ? "loading…" : "no entries"}</Text>
|
||||
) : (
|
||||
window.map((e, i) => {
|
||||
const idx = (start || 0) + i;
|
||||
const active = idx === state.selected;
|
||||
const tag =
|
||||
e.source === "official" ? "[off]" : e.source === "smithery" ? "[smt]" : "[loc]";
|
||||
const installedSpec = isInstalled(state.installedSpecs, e);
|
||||
const installedBadge = installedSpec ? " ✓" : "";
|
||||
const pop = e.popularity !== undefined ? ` · ${e.popularity.toLocaleString()}` : "";
|
||||
return (
|
||||
<Box key={e.name}>
|
||||
<Text color={active ? COLOR.brand : undefined}>{active ? "▸ " : " "}</Text>
|
||||
<Text bold={active}>{e.name.padEnd(38).slice(0, 38)}</Text>
|
||||
<Text dimColor>{` ${tag}${pop}${installedBadge}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
{selected ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold>{selected.title}</Text>
|
||||
{selected.description ? <Text dimColor>{selected.description.slice(0, 200)}</Text> : null}
|
||||
{selected.install ? (
|
||||
<Text dimColor>
|
||||
{`spec: ${selected.install.runtime} ${selected.install.packageId ?? selected.install.url ?? "—"} · ${selected.install.transport}`}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>(smithery listing — install detail fetched on Enter)</Text>
|
||||
)}
|
||||
{selected.install?.requiredEnv?.length ? (
|
||||
<Text color="yellow">{`needs: ${selected.install.requiredEnv.join(", ")}`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
type filter · ↑↓ pick · enter{" "}
|
||||
{selected && isInstalled(state.installedSpecs, selected) ? "uninstall" : "install"} · PgDn
|
||||
load more · esc close
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
/** Modal picker for `PlanCheckpointError`: continue / revise / stop. */
|
||||
|
||||
import { Box } from "ink";
|
||||
import React from "react";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
|
||||
export type CheckpointChoice = "continue" | "revise" | "stop";
|
||||
|
||||
export interface PlanCheckpointConfirmProps {
|
||||
stepId: string;
|
||||
title?: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
/** Full step list from the approved plan, when available. */
|
||||
steps?: PlanStep[];
|
||||
/** Set of stepIds the model has marked complete so far. */
|
||||
completedStepIds?: Set<string>;
|
||||
onChoose: (choice: CheckpointChoice) => void;
|
||||
}
|
||||
|
||||
function PlanCheckpointConfirmInner({
|
||||
stepId,
|
||||
title,
|
||||
completed,
|
||||
total,
|
||||
steps,
|
||||
completedStepIds,
|
||||
onChoose,
|
||||
}: PlanCheckpointConfirmProps) {
|
||||
const stepRows = steps?.length ?? 0;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(14, stepRows + 12) });
|
||||
|
||||
const label = title ? `${stepId} · ${title}` : stepId;
|
||||
const counter = total > 0 ? `${completed}/${total}` : "";
|
||||
const isLast = total > 0 && completed >= total;
|
||||
const statuses = buildStatusMap(steps, completedStepIds, stepId, isLast);
|
||||
const subtitle = counter ? `${counter} · ${label}` : label;
|
||||
return (
|
||||
<ApprovalCard tone="ok" glyph="⛁" title="Checkpoint — step done" metaRight={subtitle}>
|
||||
{steps && steps.length > 0 ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<PlanStepList steps={steps} statuses={statuses} focusStepId={stepId} />
|
||||
</Box>
|
||||
) : null}
|
||||
<SingleSelect
|
||||
initialValue={isLast ? "stop" : "continue"}
|
||||
items={[
|
||||
{
|
||||
value: "continue",
|
||||
label: "Continue — run the next step",
|
||||
hint: "Model resumes with the next step.",
|
||||
},
|
||||
{
|
||||
value: "revise",
|
||||
label: "Revise — give feedback before the next step",
|
||||
hint: "Stay paused, type guidance; model adjusts the remaining plan.",
|
||||
},
|
||||
{
|
||||
value: "stop",
|
||||
label: "Stop — end the plan here",
|
||||
hint: "Model summarizes what was done and ends.",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as CheckpointChoice)}
|
||||
onCancel={() => onChoose("stop")}
|
||||
/>
|
||||
</ApprovalCard>
|
||||
);
|
||||
}
|
||||
|
||||
export const PlanCheckpointConfirm = React.memo(PlanCheckpointConfirmInner);
|
||||
|
||||
/** Current step renders as "done" — flush order isn't guaranteed at picker time. */
|
||||
function buildStatusMap(
|
||||
steps: PlanStep[] | undefined,
|
||||
completedStepIds: Set<string> | undefined,
|
||||
currentStepId: string,
|
||||
isLast: boolean,
|
||||
): Map<string, StepStatus> {
|
||||
const map = new Map<string, StepStatus>();
|
||||
if (!steps) return map;
|
||||
for (const step of steps) {
|
||||
if (completedStepIds?.has(step.id) || step.id === currentStepId) {
|
||||
map.set(step.id, "done");
|
||||
} else {
|
||||
map.set(step.id, "pending");
|
||||
}
|
||||
}
|
||||
void isLast;
|
||||
return map;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -20,20 +19,25 @@ import { FG, SURFACE, TONE } from "./theme/tokens.js";
|
||||
|
||||
/** Raw-stdin keystroke bus → multiline reducer; one logical line per Box row, viewport-clipped. */
|
||||
|
||||
/** Pastes shorter than this AND single-line render verbatim; longer ones become a `[paste #N · …]` sentinel chip (#397). */
|
||||
export const INLINE_PASTE_THRESHOLD = 200;
|
||||
|
||||
export function shouldInlinePaste(content: string): boolean {
|
||||
return !content.includes("\n") && content.length <= INLINE_PASTE_THRESHOLD;
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
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 +48,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);
|
||||
|
||||
@@ -71,15 +81,19 @@ export function PromptInput({
|
||||
const registerPaste = (content: string) => {
|
||||
const v = lastLocalValueRef.current;
|
||||
const c = cursorRef.current;
|
||||
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
|
||||
nextPasteIdRef.current = id + 1;
|
||||
pastesRef.current.set(id, makePasteEntry(id, content));
|
||||
const sentinel = encodePasteSentinel(id);
|
||||
const next = v.slice(0, c) + sentinel + v.slice(c);
|
||||
const insertion = shouldInlinePaste(content)
|
||||
? content
|
||||
: (() => {
|
||||
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
|
||||
nextPasteIdRef.current = id + 1;
|
||||
pastesRef.current.set(id, makePasteEntry(id, content));
|
||||
return encodePasteSentinel(id);
|
||||
})();
|
||||
const next = v.slice(0, c) + insertion + v.slice(c);
|
||||
lastLocalValueRef.current = next;
|
||||
cursorRef.current = c + 1;
|
||||
cursorRef.current = c + insertion.length;
|
||||
onChange(next);
|
||||
setCursor(c + 1);
|
||||
setCursor(c + insertion.length);
|
||||
};
|
||||
|
||||
useKeystroke((ev) => {
|
||||
@@ -132,6 +146,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 +169,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;
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface SingleSelectProps<V extends string> {
|
||||
initialValue?: V;
|
||||
onSubmit: (value: V) => void;
|
||||
onCancel?: () => void;
|
||||
/** Fired when Tab is pressed on the currently highlighted item. */
|
||||
onTab?: (value: V) => void;
|
||||
/** Optional dim footer beneath the list. */
|
||||
footer?: string;
|
||||
}
|
||||
@@ -27,6 +29,7 @@ export function SingleSelect<V extends string>({
|
||||
items,
|
||||
initialValue,
|
||||
onSubmit,
|
||||
onTab,
|
||||
onCancel,
|
||||
footer,
|
||||
}: SingleSelectProps<V>) {
|
||||
@@ -45,6 +48,9 @@ export function SingleSelect<V extends string>({
|
||||
} else if (ev.return) {
|
||||
const chosen = items[index];
|
||||
if (chosen && !chosen.disabled) onSubmit(chosen.value);
|
||||
} else if (ev.tab) {
|
||||
const chosen = items[index];
|
||||
if (chosen && !chosen.disabled) onTab?.(chosen.value);
|
||||
} else if (ev.escape && onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 React, { useMemo, useState } from "react";
|
||||
import type { SessionInfo } from "../../memory/session.js";
|
||||
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.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 +17,10 @@ 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;
|
||||
/** When provided, broadcasts to the web dashboard so it can resolve via `/api/modal/resolve`. */
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 6;
|
||||
@@ -24,6 +29,8 @@ export function SessionPicker({
|
||||
sessions,
|
||||
workspace,
|
||||
onChoose,
|
||||
walletCurrency,
|
||||
pickerPorts,
|
||||
}: SessionPickerProps): React.ReactElement {
|
||||
const [focus, setFocus] = useState(0);
|
||||
const [renaming, setRenaming] = useState<{ from: string; buf: string } | null>(null);
|
||||
@@ -31,6 +38,53 @@ export function SessionPicker({
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
|
||||
|
||||
const snapshot = useMemo(
|
||||
() => ({
|
||||
pickerKind: "sessions",
|
||||
title: `pick a session — ${workspace}`,
|
||||
items: sessions.map((s) => {
|
||||
const branch = s.meta.branch ?? "main";
|
||||
const summary =
|
||||
s.meta.summary ?? `${s.messageCount} message${s.messageCount === 1 ? "" : "s"}`;
|
||||
const turns = s.meta.turnCount ?? Math.ceil(s.messageCount / 2);
|
||||
const currency = walletCurrency ?? s.meta.balanceCurrency;
|
||||
const costLabel =
|
||||
s.meta.totalCostUsd !== undefined ? formatCost(s.meta.totalCostUsd, currency, 2) : "";
|
||||
return {
|
||||
id: s.name,
|
||||
title: s.name,
|
||||
subtitle: summary,
|
||||
badge: branch,
|
||||
meta: costLabel ? `${turns} turns · ${costLabel}` : `${turns} turns`,
|
||||
};
|
||||
}),
|
||||
actions: ["pick", "delete", "rename", "new", "cancel"] as const,
|
||||
hint: "↑↓ pick · ⏎ open · [n] new · [d] delete · [r] rename · esc quit",
|
||||
}),
|
||||
[sessions, workspace, walletCurrency],
|
||||
);
|
||||
|
||||
usePickerBroadcast(
|
||||
!!pickerPorts,
|
||||
{
|
||||
...snapshot,
|
||||
actions: [...snapshot.actions],
|
||||
},
|
||||
(res) => {
|
||||
if (res.action === "pick") return onChoose({ kind: "open", name: res.id });
|
||||
if (res.action === "delete") return onChoose({ kind: "delete", name: res.id });
|
||||
if (res.action === "rename")
|
||||
return onChoose({ kind: "rename", name: res.id, newName: res.text });
|
||||
if (res.action === "new") return onChoose({ kind: "new" });
|
||||
if (res.action === "cancel") return onChoose({ kind: "quit" });
|
||||
},
|
||||
pickerPorts ?? {
|
||||
broadcast: () => undefined,
|
||||
resolverRef: { current: null },
|
||||
snapshotRef: { current: null },
|
||||
},
|
||||
);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.paste) {
|
||||
if (renaming) setRenaming({ ...renaming, buf: renaming.buf + ev.input });
|
||||
@@ -101,7 +155,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 +196,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 +221,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";
|
||||
@@ -35,7 +34,7 @@ export function ShellConfirm({ command, allowPrefix, kind, onChoose }: ShellConf
|
||||
glyph="✗"
|
||||
title="Deny — provide context"
|
||||
metaRight="optional"
|
||||
footerHint="⏎ submit · esc skip (deny without reason)"
|
||||
footerHint="type context · ⏎ submit with reason · esc skip (deny without reason)"
|
||||
>
|
||||
<DenyContextInput
|
||||
onSubmit={(context) => onChoose("deny", context || undefined)}
|
||||
@@ -51,7 +50,7 @@ export function ShellConfirm({ command, allowPrefix, kind, onChoose }: ShellConf
|
||||
glyph={isBackground ? "⏱" : "?"}
|
||||
title={isBackground ? "Background process" : "Shell command"}
|
||||
metaRight="awaiting"
|
||||
footerHint="↑↓ pick · ⏎ confirm · esc cancel"
|
||||
footerHint="↑↓ pick · ⏎ confirm · Tab add context · esc cancel"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={FG.faint}>{subtitle}</Text>
|
||||
@@ -80,13 +79,16 @@ export function ShellConfirm({ command, allowPrefix, kind, onChoose }: ShellConf
|
||||
{
|
||||
value: "deny",
|
||||
label: "deny",
|
||||
hint: "skip; agent will pick an alternative",
|
||||
hint: "press Tab to add context telling the model why",
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => {
|
||||
if (v === "deny") setPhase("deny");
|
||||
else onChoose(v as ShellConfirmChoice);
|
||||
}}
|
||||
onTab={(v) => {
|
||||
if (v === "deny") setPhase("deny");
|
||||
}}
|
||||
onCancel={() => onChoose("deny")}
|
||||
/>
|
||||
</ApprovalCard>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
/**
|
||||
* First-run / re-configure wizard.
|
||||
*
|
||||
* Walks a new user through: API key → preset pick → MCP server pick →
|
||||
* per-server args → save. Saved output lives in `~/.reasonix/config.json`
|
||||
* so the next `reasonix chat` starts with everything already wired.
|
||||
*
|
||||
* The wizard is the antidote to "too many CLI flags" — a new user should
|
||||
* never have to read `--help` to get MCP + a sensible model combo
|
||||
* working. Everything a user could set via `--mcp`, `--harvest`,
|
||||
* `--branch`, etc. can be picked here in a few arrow-key presses.
|
||||
* Walks a new user through: language → API key → preset pick → MCP server
|
||||
* pick → per-server args → save. Saved output lives in
|
||||
* `~/.reasonix/config.json` so the next `reasonix chat` starts with
|
||||
* everything already wired.
|
||||
*/
|
||||
|
||||
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
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type PresetName,
|
||||
type ReasonixConfig,
|
||||
@@ -24,6 +21,16 @@ import {
|
||||
redactKey,
|
||||
writeConfig,
|
||||
} from "../../config.js";
|
||||
import {
|
||||
detectSystemLanguage,
|
||||
getLanguage,
|
||||
getSupportedLanguages,
|
||||
notifyLanguageChange,
|
||||
onLanguageChange,
|
||||
setLanguage,
|
||||
t,
|
||||
} from "../../i18n/index.js";
|
||||
import type { LanguageCode } from "../../i18n/types.js";
|
||||
import { type CatalogEntry, MCP_CATALOG } from "../../mcp/catalog.js";
|
||||
import { MultiSelect, type SelectItem, SingleSelect } from "./Select.js";
|
||||
import { PRESET_DESCRIPTIONS } from "./presets.js";
|
||||
@@ -42,22 +49,31 @@ export interface WizardProps {
|
||||
};
|
||||
}
|
||||
|
||||
type Step = "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
|
||||
type Step = "language" | "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
|
||||
|
||||
interface WizardData {
|
||||
language: LanguageCode;
|
||||
apiKey: string;
|
||||
preset: PresetName;
|
||||
selectedCatalog: string[]; // entries from MCP_CATALOG by `name`
|
||||
/** Captured user inputs per catalog entry that has `userArgs` (e.g. fs dir). */
|
||||
selectedCatalog: string[];
|
||||
catalogArgs: Record<string, string>;
|
||||
}
|
||||
|
||||
const CATALOG_BY_NAME = new Map(MCP_CATALOG.map((e) => [e.name, e]));
|
||||
|
||||
const LANGUAGE_LABELS: Record<LanguageCode, string> = {
|
||||
EN: "English",
|
||||
"zh-CN": "简体中文",
|
||||
};
|
||||
|
||||
export function Wizard({ onComplete, onCancel, existingApiKey, initial }: WizardProps) {
|
||||
const { exit } = useApp();
|
||||
const [step, setStep] = useState<Step>(existingApiKey ? "preset" : "apiKey");
|
||||
const [, setLanguageVersion] = useState(0);
|
||||
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
|
||||
|
||||
const [step, setStep] = useState<Step>("language");
|
||||
const [data, setData] = useState<WizardData>({
|
||||
language: getLanguage(),
|
||||
apiKey: existingApiKey ?? "",
|
||||
preset: initial?.preset ?? "auto",
|
||||
selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
|
||||
@@ -65,12 +81,24 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Global Esc → cancel. Disabled once we've started saving to avoid
|
||||
// ejecting out of a half-written state.
|
||||
useInput((_input, key) => {
|
||||
if (key.escape && step !== "saved" && onCancel) onCancel();
|
||||
});
|
||||
|
||||
if (step === "language") {
|
||||
return (
|
||||
<LanguageStep
|
||||
initialValue={data.language}
|
||||
onSubmit={(lang) => {
|
||||
setLanguage(lang);
|
||||
notifyLanguageChange();
|
||||
setData((d) => ({ ...d, language: lang }));
|
||||
setStep(existingApiKey ? "preset" : "apiKey");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "apiKey") {
|
||||
return (
|
||||
<ApiKeyStep
|
||||
@@ -87,7 +115,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
|
||||
if (step === "preset") {
|
||||
return (
|
||||
<StepFrame title="Pick a preset" step={1} total={3}>
|
||||
<StepFrame title={t("wizard.presetTitle")} step={1} total={3}>
|
||||
<SingleSelect<PresetName>
|
||||
items={presetItems()}
|
||||
initialValue={data.preset}
|
||||
@@ -97,7 +125,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
}}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] navigate · [Enter] confirm · [Esc] cancel</Text>
|
||||
<Text dimColor>{t("wizard.selectFooter")}</Text>
|
||||
</Box>
|
||||
</StepFrame>
|
||||
);
|
||||
@@ -105,17 +133,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
|
||||
if (step === "mcp") {
|
||||
return (
|
||||
<StepFrame title="Which MCP servers should Reasonix wire up for you?" step={2} total={3}>
|
||||
<StepFrame title={t("wizard.mcpTitle")} step={2} total={3}>
|
||||
<MultiSelect
|
||||
items={mcpItems()}
|
||||
initialSelected={data.selectedCatalog}
|
||||
onSubmit={(selected) => {
|
||||
setData((d) => ({ ...d, selectedCatalog: selected }));
|
||||
// Only advance to the args step if any selected entry needs args.
|
||||
const needsArgs = selected.some((name) => CATALOG_BY_NAME.get(name)?.userArgs);
|
||||
setStep(needsArgs ? "mcpArgs" : "review");
|
||||
}}
|
||||
footer="[↑↓] navigate · [Space] toggle · [Enter] confirm · [Esc] cancel · empty = skip"
|
||||
footer={t("wizard.mcpFooterMulti")}
|
||||
/>
|
||||
</StepFrame>
|
||||
);
|
||||
@@ -151,13 +178,21 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
if (step === "review") {
|
||||
const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
|
||||
return (
|
||||
<StepFrame title="Ready to save" step={3} total={3}>
|
||||
<StepFrame title={t("wizard.reviewTitle")} step={3} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<SummaryLine label="API key" value={redactKey(data.apiKey)} />
|
||||
<SummaryLine label="Preset" value={data.preset} />
|
||||
<SummaryLine
|
||||
label="MCP"
|
||||
value={specs.length === 0 ? "(none)" : `${specs.length} server(s)`}
|
||||
label={t("wizard.reviewLabelLanguage")}
|
||||
value={LANGUAGE_LABELS[data.language]}
|
||||
/>
|
||||
<SummaryLine label={t("wizard.reviewLabelApiKey")} value={redactKey(data.apiKey)} />
|
||||
<SummaryLine label={t("wizard.reviewLabelPreset")} value={data.preset} />
|
||||
<SummaryLine
|
||||
label={t("wizard.reviewLabelMcp")}
|
||||
value={
|
||||
specs.length === 0
|
||||
? t("wizard.reviewMcpNone")
|
||||
: t("wizard.reviewMcpServers", { count: specs.length })
|
||||
}
|
||||
/>
|
||||
{specs.map((spec, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: review-only render, order fixed
|
||||
@@ -166,7 +201,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text>Saves to {defaultConfigPath()}</Text>
|
||||
<Text>{t("wizard.reviewSavesTo", { path: defaultConfigPath() })}</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
@@ -174,7 +209,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[Enter] save · [Esc] cancel</Text>
|
||||
<Text dimColor>{t("wizard.reviewFooter")}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<ReviewConfirm
|
||||
@@ -195,7 +230,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
setStep("saved");
|
||||
onComplete(next);
|
||||
} catch (e) {
|
||||
setError(`Could not save config: ${(e as Error).message}`);
|
||||
setError(t("wizard.reviewSaveError", { message: (e as Error).message }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -203,17 +238,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
);
|
||||
}
|
||||
|
||||
// saved
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
|
||||
<Text bold color="green">
|
||||
▸ Saved.
|
||||
{t("wizard.savedTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Run `reasonix` any time to start chatting — your settings are remembered.</Text>
|
||||
<Text>{t("ui.welcome")}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[Enter] to exit</Text>
|
||||
<Text dimColor>{t("wizard.savedFooter")}</Text>
|
||||
</Box>
|
||||
<ExitOnEnter onExit={exit} />
|
||||
</Box>
|
||||
@@ -222,6 +256,38 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
|
||||
// ---------- step components ----------
|
||||
|
||||
function LanguageStep({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialValue: LanguageCode;
|
||||
onSubmit: (lang: LanguageCode) => void;
|
||||
}) {
|
||||
const items: SelectItem<LanguageCode>[] = getSupportedLanguages().map((code) => ({
|
||||
value: code,
|
||||
label: LANGUAGE_LABELS[code],
|
||||
hint: code === detectSystemLanguage() ? "(detected)" : undefined,
|
||||
}));
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
{t("wizard.languageTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.languageSubtitle")}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<SingleSelect<LanguageCode>
|
||||
items={items}
|
||||
initialValue={initialValue}
|
||||
onSubmit={onSubmit}
|
||||
footer={t("wizard.selectFooter")}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyStep({
|
||||
onSubmit,
|
||||
error,
|
||||
@@ -235,16 +301,16 @@ function ApiKeyStep({
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
Welcome to Reasonix.
|
||||
{t("wizard.welcomeTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Paste your DeepSeek API key to get started.</Text>
|
||||
<Text>{t("wizard.apiKeyPrompt")}</Text>
|
||||
</Box>
|
||||
<Text dimColor>Get one at: https://platform.deepseek.com/api_keys</Text>
|
||||
<Text dimColor>Saved locally to {defaultConfigPath()}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeyGetOne")}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeySavedLocally", { path: defaultConfigPath() })}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text bold color="cyan">
|
||||
{"key › "}
|
||||
{t("wizard.apiKeyInputLabel")}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
@@ -252,7 +318,7 @@ function ApiKeyStep({
|
||||
onSubmit={(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!isPlausibleKey(trimmed)) {
|
||||
onError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
|
||||
onError(t("wizard.apiKeyInvalid"));
|
||||
setValue("");
|
||||
return;
|
||||
}
|
||||
@@ -268,7 +334,7 @@ function ApiKeyStep({
|
||||
</Box>
|
||||
) : value ? (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>preview: {redactKey(value)}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeyPreview", { redacted: redactKey(value) })}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -287,8 +353,54 @@ 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(
|
||||
t("wizard.mcpArgsDirCreateFailed", {
|
||||
path: pendingCreate,
|
||||
message: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
setPendingCreate(null);
|
||||
}
|
||||
} else if (ch === "n" || key.escape) {
|
||||
setPendingCreate(null);
|
||||
onError(null);
|
||||
}
|
||||
});
|
||||
|
||||
if (pendingCreate) {
|
||||
return (
|
||||
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<Text>{t("wizard.mcpArgsDirMissing", { path: pendingCreate })}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.mcpArgsDirCreateHint")}</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}>
|
||||
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<Text>{entry.summary}</Text>
|
||||
{entry.note ? (
|
||||
@@ -297,7 +409,7 @@ function McpArgsStep({
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text>Required parameter: </Text>
|
||||
<Text>{t("wizard.mcpArgsRequiredParam")}</Text>
|
||||
<Text bold>{entry.userArgs}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
@@ -311,9 +423,20 @@ function McpArgsStep({
|
||||
onSubmit={(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
onError(`${entry.name} needs a value — got an empty string.`);
|
||||
onError(t("wizard.mcpArgsEmpty", { name: entry.name }));
|
||||
return;
|
||||
}
|
||||
if (entry.name === "filesystem") {
|
||||
const check = checkFilesystemPath(trimmed);
|
||||
if (check.kind === "missing") {
|
||||
setPendingCreate(trimmed);
|
||||
return;
|
||||
}
|
||||
if (check.kind === "not-a-dir") {
|
||||
onError(t("wizard.mcpArgsNotADir", { path: trimmed }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSubmit(trimmed);
|
||||
setValue("");
|
||||
}}
|
||||
@@ -330,6 +453,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();
|
||||
@@ -344,8 +475,6 @@ function ExitOnEnter({ onExit }: { onExit: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- small bits ----------
|
||||
|
||||
function StepFrame({
|
||||
title,
|
||||
step,
|
||||
@@ -360,9 +489,7 @@ function StepFrame({
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Step {step}/{total} ·{" "}
|
||||
</Text>
|
||||
<Text dimColor>{t("wizard.stepCounter", { step, total })}</Text>
|
||||
<Text bold color="cyan">
|
||||
{title}
|
||||
</Text>
|
||||
@@ -383,8 +510,6 @@ function SummaryLine({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- data helpers ----------
|
||||
|
||||
function presetItems(): SelectItem<PresetName>[] {
|
||||
return (["auto", "flash", "pro"] as const).map((name) => ({
|
||||
value: name as PresetName,
|
||||
@@ -396,7 +521,7 @@ function presetItems(): SelectItem<PresetName>[] {
|
||||
function mcpItems(): SelectItem<string>[] {
|
||||
return MCP_CATALOG.map((entry) => {
|
||||
const hintParts: string[] = [entry.summary];
|
||||
if (entry.userArgs) hintParts.push(`(you'll provide ${entry.userArgs})`);
|
||||
if (entry.userArgs) hintParts.push(t("wizard.mcpUserArgsHint", { arg: entry.userArgs }));
|
||||
if (entry.note) hintParts.push(entry.note);
|
||||
return {
|
||||
value: entry.name,
|
||||
@@ -413,9 +538,6 @@ function placeholderFor(entry: CatalogEntry): string {
|
||||
}
|
||||
|
||||
function deriveInitialCatalog(existingSpecs: string[]): string[] {
|
||||
// Best-effort recovery: if the user previously picked catalog entries,
|
||||
// the spec strings look like `name=npx -y <pkg> ...`. Match by package
|
||||
// so reconfigure pre-checks the same boxes.
|
||||
const packageToName = new Map(MCP_CATALOG.map((e) => [e.package, e.name]));
|
||||
const out: string[] = [];
|
||||
for (const spec of existingSpecs) {
|
||||
@@ -436,12 +558,15 @@ function deriveInitialCatalog(existingSpecs: string[]): string[] {
|
||||
*/
|
||||
export function buildSpec(name: string, argsByName: Record<string, string>): string {
|
||||
const entry = CATALOG_BY_NAME.get(name);
|
||||
if (!entry) return name; // shouldn't happen; fall back gracefully
|
||||
if (!entry) return name;
|
||||
const userArg = entry.userArgs ? argsByName[name] : undefined;
|
||||
const tail = userArg ? ` ${quoteIfNeeded(userArg)}` : "";
|
||||
return `${entry.name}=npx -y ${entry.package}${tail}`;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -145,7 +145,7 @@ export function CtxBreakdownBlock({ data }: { data: CtxBreakdownData }): React.R
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{" /compact shrinks oversized tool results · /new wipes log"}</Text>
|
||||
<Text dimColor>{" /compact folds (auto at 50%) · /new wipes log"}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type {
|
||||
DashboardEvent,
|
||||
PickerAction,
|
||||
PickerItem,
|
||||
PickerResolution,
|
||||
} from "../../../server/context.js";
|
||||
|
||||
export interface PickerSnapshot {
|
||||
pickerKind: string;
|
||||
title: string;
|
||||
query?: string;
|
||||
items: PickerItem[];
|
||||
actions: PickerAction[];
|
||||
hasMore?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ViewerSnapshot {
|
||||
viewerKind: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
steps?: Array<{ id: string; title: string; status: "done" | "queued" }>;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface ViewerBroadcastPorts {
|
||||
broadcast: (ev: DashboardEvent) => void;
|
||||
resolverRef: MutableRefObject<(() => void) | null>;
|
||||
snapshotRef: MutableRefObject<ViewerSnapshot | null>;
|
||||
}
|
||||
|
||||
/** Read-only sibling of `usePickerBroadcast` — viewer modals carry no selection so only `close` flows back. */
|
||||
export function useViewerBroadcast(
|
||||
active: boolean,
|
||||
snapshot: ViewerSnapshot,
|
||||
onClose: () => void,
|
||||
ports: ViewerBroadcastPorts,
|
||||
): void {
|
||||
const { broadcast, resolverRef, snapshotRef } = ports;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return () => {
|
||||
broadcast({ kind: "modal-down", modalKind: "viewer" });
|
||||
if (resolverRef.current) resolverRef.current = null;
|
||||
if (snapshotRef.current) snapshotRef.current = null;
|
||||
};
|
||||
}, [active, broadcast, resolverRef, snapshotRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
snapshotRef.current = snapshot;
|
||||
resolverRef.current = onClose;
|
||||
broadcast({ kind: "modal-up", modal: { kind: "viewer", ...snapshot } });
|
||||
}, [active, snapshot, onClose, broadcast, resolverRef, snapshotRef]);
|
||||
}
|
||||
|
||||
export interface PickerBroadcastPorts {
|
||||
broadcast: (ev: DashboardEvent) => void;
|
||||
resolverRef: MutableRefObject<((res: PickerResolution) => void) | null>;
|
||||
snapshotRef: MutableRefObject<PickerSnapshot | null>;
|
||||
}
|
||||
|
||||
/** Mirrors a TUI picker into the dashboard via modal-up/down events. Caller passes stable refs from App.tsx so identity does not churn the effect. */
|
||||
export function usePickerBroadcast(
|
||||
active: boolean,
|
||||
snapshot: PickerSnapshot,
|
||||
onResolve: (res: PickerResolution) => void,
|
||||
ports: PickerBroadcastPorts,
|
||||
): void {
|
||||
const { broadcast, resolverRef, snapshotRef } = ports;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return () => {
|
||||
broadcast({ kind: "modal-down", modalKind: "picker" });
|
||||
if (resolverRef.current) resolverRef.current = null;
|
||||
if (snapshotRef.current) snapshotRef.current = null;
|
||||
};
|
||||
}, [active, broadcast, resolverRef, snapshotRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
snapshotRef.current = snapshot;
|
||||
resolverRef.current = onResolve;
|
||||
broadcast({ kind: "modal-up", modal: { kind: "picker", ...snapshot } });
|
||||
}, [active, snapshot, onResolve, broadcast, resolverRef, snapshotRef]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export interface ToolEventContext {
|
||||
toolStartedAtRef: MutableRefObject<number | null>;
|
||||
toolHistoryRef: MutableRefObject<Array<{ toolName: string; text: string }>>;
|
||||
setPendingShell: Dispatch<
|
||||
SetStateAction<{ command: string; kind: "run_command" | "run_background" } | null>
|
||||
SetStateAction<{ id: number; command: string; kind: "run_command" | "run_background" } | null>
|
||||
>;
|
||||
setPendingPlan: Dispatch<SetStateAction<string | null>>;
|
||||
setPendingRevision: Dispatch<
|
||||
@@ -52,122 +52,6 @@ export function handleToolEvent(ev: LoopEvent, ctx: ToolEventContext): void {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.codeModeOn &&
|
||||
(ev.toolName === "run_command" || ev.toolName === "run_background") &&
|
||||
ev.content.includes('"NeedsConfirmationError:') &&
|
||||
ev.toolArgs
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.toolArgs) as { command?: unknown };
|
||||
if (typeof parsed.command === "string" && parsed.command.trim()) {
|
||||
ctx.setPendingShell({
|
||||
command: parsed.command.trim(),
|
||||
kind: ev.toolName as "run_command" | "run_background",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* malformed args — skip the prompt */
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.codeModeOn &&
|
||||
ev.toolName === "submit_plan" &&
|
||||
ev.content.includes('"PlanProposedError:')
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.content) as {
|
||||
plan?: unknown;
|
||||
steps?: unknown;
|
||||
summary?: unknown;
|
||||
};
|
||||
if (typeof parsed.plan === "string" && parsed.plan.trim()) {
|
||||
const planText = parsed.plan.trim();
|
||||
ctx.setPendingPlan(planText);
|
||||
const steps = Array.isArray(parsed.steps) ? (parsed.steps as PlanStep[]) : null;
|
||||
ctx.planStepsRef.current = steps;
|
||||
ctx.completedStepIdsRef.current = new Set();
|
||||
ctx.planBodyRef.current = planText;
|
||||
ctx.planSummaryRef.current =
|
||||
typeof parsed.summary === "string" && parsed.summary.trim()
|
||||
? parsed.summary.trim()
|
||||
: null;
|
||||
ctx.persistPlanState();
|
||||
const summary =
|
||||
typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : "";
|
||||
const title = summary ? `Plan · ${summary}` : "Plan submitted";
|
||||
ctx.log.showPlan({
|
||||
title,
|
||||
steps: (steps ?? []).map((s) => ({ id: s.id, title: s.title, status: "queued" })),
|
||||
variant: "active",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* malformed payload — skip the picker */
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.toolName === "revise_plan" && ev.content.includes('"PlanRevisionProposedError:')) {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.content) as {
|
||||
reason?: unknown;
|
||||
remainingSteps?: unknown;
|
||||
summary?: unknown;
|
||||
};
|
||||
const reason = typeof parsed.reason === "string" ? parsed.reason.trim() : "";
|
||||
const remainingSteps = Array.isArray(parsed.remainingSteps)
|
||||
? (parsed.remainingSteps as PlanStep[]).filter(
|
||||
(s) =>
|
||||
s &&
|
||||
typeof s.id === "string" &&
|
||||
s.id.trim() &&
|
||||
typeof s.title === "string" &&
|
||||
s.title.trim() &&
|
||||
typeof s.action === "string" &&
|
||||
s.action.trim(),
|
||||
)
|
||||
: [];
|
||||
if (reason && remainingSteps.length > 0) {
|
||||
const summary =
|
||||
typeof parsed.summary === "string" ? parsed.summary.trim() || undefined : undefined;
|
||||
ctx.setPendingRevision({ reason, remainingSteps, summary });
|
||||
}
|
||||
} catch {
|
||||
/* malformed payload — skip the picker */
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.toolName === "ask_choice" && ev.content.includes('"ChoiceRequestedError:')) {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.content) as {
|
||||
question?: unknown;
|
||||
options?: unknown;
|
||||
allowCustom?: unknown;
|
||||
};
|
||||
const question = typeof parsed.question === "string" ? parsed.question.trim() : "";
|
||||
const options = Array.isArray(parsed.options)
|
||||
? (parsed.options as ChoiceOption[]).filter(
|
||||
(o) =>
|
||||
o &&
|
||||
typeof o.id === "string" &&
|
||||
o.id.trim() &&
|
||||
typeof o.title === "string" &&
|
||||
o.title.trim(),
|
||||
)
|
||||
: [];
|
||||
if (question && options.length >= 2) {
|
||||
ctx.setPendingChoice({
|
||||
question,
|
||||
options,
|
||||
allowCustom: parsed.allowCustom === true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* malformed payload — skip the picker */
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.toolName === "mark_step_complete") {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.content) as Partial<StepCompletion>;
|
||||
|
||||
@@ -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: {
|
||||
@@ -71,11 +72,11 @@ export interface Scrollback {
|
||||
): void;
|
||||
endBranch(id: string, aborted?: boolean): void;
|
||||
|
||||
startReasoning(): string;
|
||||
startReasoning(model?: string): string;
|
||||
appendReasoning(id: string, chunk: string): void;
|
||||
endReasoning(id: string, paragraphs: number, tokens: number, aborted?: boolean): void;
|
||||
|
||||
startStreaming(): string;
|
||||
startStreaming(model?: string): string;
|
||||
appendStreaming(id: string, chunk: string): void;
|
||||
endStreaming(id: string, aborted?: boolean): void;
|
||||
|
||||
@@ -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;
|
||||
@@ -247,9 +249,9 @@ export function useScrollback(): Scrollback {
|
||||
endBranch(id, aborted) {
|
||||
dispatch({ type: "branch.end", id, aborted });
|
||||
},
|
||||
startReasoning() {
|
||||
startReasoning(model) {
|
||||
const id = nextId("r");
|
||||
dispatch({ type: "reasoning.start", id });
|
||||
dispatch({ type: "reasoning.start", id, ...(model ? { model } : {}) });
|
||||
return id;
|
||||
},
|
||||
appendReasoning(id, chunk) {
|
||||
@@ -258,9 +260,9 @@ export function useScrollback(): Scrollback {
|
||||
endReasoning(id, paragraphs, tokens, aborted) {
|
||||
dispatch({ type: "reasoning.end", id, paragraphs, tokens, aborted });
|
||||
},
|
||||
startStreaming() {
|
||||
startStreaming(model) {
|
||||
const id = nextId("s");
|
||||
dispatch({ type: "streaming.start", id });
|
||||
dispatch({ type: "streaming.start", id, ...(model ? { model } : {}) });
|
||||
return id;
|
||||
},
|
||||
appendStreaming(id, chunk) {
|
||||
|
||||
@@ -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);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform)
|
||||
import React from "react";
|
||||
import type { ApplyResult } from "../../../code/edit-blocks.js";
|
||||
@@ -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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
@@ -79,17 +80,7 @@ export function ModeStatusBar({
|
||||
}
|
||||
|
||||
function ModeBarFrame({ children }: { children: React.ReactNode }) {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const ruleWidth = Math.max(20, cols - 2);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1}>
|
||||
<Text color={FG.faint}>{"╌".repeat(ruleWidth)}</Text>
|
||||
</Box>
|
||||
<Box paddingX={1}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
return <Box paddingX={1}>{children}</Box>;
|
||||
}
|
||||
|
||||
function ModePill({
|
||||
@@ -112,17 +103,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 +126,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 +157,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 +208,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, 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,9 @@ export function StatusRow(): React.ReactElement {
|
||||
<Text bold color={TONE.brand}>
|
||||
{"▸ "}
|
||||
</Text>
|
||||
<Text bold color={FG.body}>{`¥${turnCny.toFixed(4)} turn`}</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
<Text color={FG.sub}>{`¥${sessionCny.toFixed(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={FG.body}>
|
||||
{`${formatCost(status.cost, status.balanceCurrency)} turn`}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
|
||||