Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7526167d32 | ||
|
|
32126dece4 | ||
|
|
73000ce388 | ||
|
|
c86279a493 | ||
|
|
fbcd82096c | ||
|
|
37b4cca4f6 | ||
|
|
3d692ead01 | ||
|
|
a9024a8d31 | ||
|
|
a8e7ebeb47 | ||
|
|
88e5becf06 | ||
|
|
8685215e23 | ||
|
|
7b108cc2c0 | ||
|
|
948f361849 | ||
|
|
d65eeddac6 | ||
|
|
e78112bf4f | ||
|
|
51a5f9dd8e | ||
|
|
b5fd33700e | ||
|
|
b24d2350ed | ||
|
|
9f8df4da6b | ||
|
|
6e2e869aaa | ||
|
|
ff6edff2f6 | ||
|
|
213b4ec198 | ||
|
|
5b5ff09088 | ||
|
|
a92b3c042a | ||
|
|
a5abd9ef94 | ||
|
|
38867e5c92 | ||
|
|
99754672af | ||
|
|
d5c02bfae2 | ||
|
|
b140d1c1fb | ||
|
|
3b070dce9c | ||
|
|
08e5ddd1f0 | ||
|
|
c3ab3fcb52 | ||
|
|
4556fb0f09 | ||
|
|
c80be26517 | ||
|
|
252e1258bd | ||
|
|
5bd2678d51 | ||
|
|
f01f12d0ae | ||
|
|
b4b196ea3a | ||
|
|
ada301ffce | ||
|
|
8f6a12767c | ||
|
|
fb80b9cb38 | ||
|
|
ce01092f9c | ||
|
|
0bd11e1e63 | ||
|
|
42dba23bf7 | ||
|
|
ea3bdbed4d | ||
|
|
7cd9456440 | ||
|
|
9bf23b49ca | ||
|
|
e931db397e | ||
|
|
184a4a0366 | ||
|
|
4d58eef324 | ||
|
|
c2603a897e | ||
|
|
d9684bbff5 | ||
|
|
02ce601ef2 | ||
|
|
4ee324b02e | ||
|
|
fd64c2f8e5 | ||
|
|
a9df67102b | ||
|
|
a3b346848f | ||
|
|
966ddeac3b | ||
|
|
9e206a14ad | ||
|
|
c557b9726b | ||
|
|
5e040ad2e5 | ||
|
|
0e893e1c0a | ||
|
|
d6fde1786d | ||
|
|
ac3e4f1d56 | ||
|
|
e250afc007 | ||
|
|
f28c67fddd | ||
|
|
3f15d000e8 | ||
|
|
19c33d1a9a | ||
|
|
25240984cb | ||
|
|
e8594a973f | ||
|
|
35637b15aa | ||
|
|
d0ab2cbd42 | ||
|
|
ed1ad08615 | ||
|
|
40fe1dc546 | ||
|
|
266a4d509e | ||
|
|
aa87a6d88b | ||
|
|
a6fd807b49 | ||
|
|
3634bdeca5 | ||
|
|
933d01060f | ||
|
|
4026baa28c | ||
|
|
af4e9e33ef | ||
|
|
eacb4e56d3 | ||
|
|
c222808985 | ||
|
|
0b0e19fc7a | ||
|
|
0da4c78ca0 | ||
|
|
161e36a461 | ||
|
|
559e684784 | ||
|
|
ea66fb4bf1 | ||
|
|
edf4233a60 | ||
|
|
4b4d125987 | ||
|
|
5631dafdde | ||
|
|
e11fd6c848 | ||
|
|
11c7150ed1 | ||
|
|
4a3f5f035b | ||
|
|
2ed06d4213 | ||
|
|
e625e01e2e | ||
|
|
b2c9d416d2 |
No files matched your search
@@ -0,0 +1 @@
|
||||
github: esengine
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,151 @@
|
||||
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.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.
|
||||
Frame writes are now wrapped in DEC 2026 synchronized-output markers so
|
||||
supporting terminals can't paint a half-cleared intermediate state, and
|
||||
`marked` is bumped to v15 to stop pre-escaping inline text into HTML
|
||||
entities — which both displayed wrong and miscalculated wrap widths.
|
||||
|
||||
**Renderer:**
|
||||
|
||||
- fix(renderer): wrap commit writes in DEC 2026 sync to suppress
|
||||
flicker. The commit / static / resize paths buffered bytes into a
|
||||
single write but the terminal could still paint the cleared-then-
|
||||
repainted intermediate state. Each frame now goes out wrapped in
|
||||
`\x1b[?2026h…l`; supporting terminals (Windows Terminal ≥1.18,
|
||||
iTerm2, Kitty, Wezterm, alacritty, foot) swap frames atomically,
|
||||
others ignore the unknown CSI. Resize's screen clear is also folded
|
||||
into the next commit so clear+repaint is one sync block. Closes #225.
|
||||
|
||||
**Markdown:**
|
||||
|
||||
- fix(deps): bump `marked` to v15 — v12 pre-escaped inline text to HTML
|
||||
entities (`<` → `<`, `"` → `"`), which displayed wrong in the
|
||||
TUI and miscalculated cell widths so content past the wrap edge could
|
||||
be clipped. v15 keeps `token.text` literal and only escapes at the
|
||||
HTML renderer layer, which matches our actual rendering path.
|
||||
|
||||
## [0.23.1] — 2026-05-02
|
||||
|
||||
**Headline:** Two follow-up fixes to 0.23.0 — the `ReasoningCard` and
|
||||
|
||||
@@ -1,123 +1,169 @@
|
||||
<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/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
|
||||
<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.
|
||||
|
||||
<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) | — |
|
||||
| Persistent per-workspace sessions | yes | partial | n/a | — |
|
||||
| Plan mode · MCP · hooks · skills | yes | yes | yes | partial |
|
||||
| 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/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)
|
||||
- [#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,169 @@
|
||||
<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/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`。
|
||||
|
||||
<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/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/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>
|
||||
@@ -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." },
|
||||
];
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { MODE } from "./src/lib/api";
|
||||
import { ToastStack, appBus } from "./src/lib/bus";
|
||||
import { ErrorBoundary, ErrorOverlay } from "./src/lib/error-boundary";
|
||||
import { initLangFromServer, t, useLang } from "./src/i18n";
|
||||
import { ChatPanel } from "./src/panels/chat";
|
||||
import { HooksPanel } from "./src/panels/hooks";
|
||||
import { McpPanel } from "./src/panels/mcp";
|
||||
@@ -23,42 +24,50 @@ import { UsagePanel } from "./src/panels/usage";
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const TAB_SECTIONS = [
|
||||
{
|
||||
label: "workspace",
|
||||
tabs: [
|
||||
{ id: "chat", name: "Chat", glyph: "◆", panel: () => html`<${ChatPanel} />` },
|
||||
{ id: "plans", name: "Plans", glyph: "⊞", panel: () => html`<${PlansPanel} />` },
|
||||
{ id: "sessions", name: "Sessions", glyph: "›", panel: () => html`<${SessionsPanel} />` },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "observe",
|
||||
tabs: [
|
||||
{ id: "overview", name: "Overview", glyph: "◈", panel: () => html`<${OverviewPanel} />` },
|
||||
{ id: "usage", name: "Usage", glyph: "$", panel: () => html`<${UsagePanel} />` },
|
||||
{ id: "health", name: "System", glyph: "+", panel: () => html`<${SystemPanel} />` },
|
||||
{ id: "semantic", name: "Semantic", glyph: "≈", panel: () => html`<${SemanticPanel} />` },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "configure",
|
||||
tabs: [
|
||||
{ id: "tools", name: "Tools", glyph: "▣", panel: () => html`<${ToolsPanel} />` },
|
||||
{ id: "permissions", name: "Permissions", glyph: "▎", panel: () => html`<${PermissionsPanel} />` },
|
||||
{ id: "mcp", name: "MCP", glyph: "M", panel: () => html`<${McpPanel} />` },
|
||||
{ id: "skills", name: "Skills", glyph: "S", panel: () => html`<${SkillsPanel} />` },
|
||||
{ id: "memory", name: "Memory", glyph: "·", panel: () => html`<${MemoryPanel} />` },
|
||||
{ id: "hooks", name: "Hooks", glyph: "H", panel: () => html`<${HooksPanel} />` },
|
||||
{ id: "settings", name: "Settings", glyph: "⌘", panel: () => html`<${SettingsPanel} />` },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_TABS = TAB_SECTIONS.flatMap((s) => s.tabs);
|
||||
function tabSections() {
|
||||
return [
|
||||
{
|
||||
label: t("app.sectionWorkspace"),
|
||||
tabs: [
|
||||
{ id: "chat", name: t("app.tabChat"), glyph: "◆", panel: () => html`<${ChatPanel} />` },
|
||||
{ id: "plans", name: t("app.tabPlans"), glyph: "⊞", panel: () => html`<${PlansPanel} />` },
|
||||
{ id: "sessions", name: t("app.tabSessions"), glyph: "›", panel: () => html`<${SessionsPanel} />` },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("app.sectionObserve"),
|
||||
tabs: [
|
||||
{ id: "overview", name: t("app.tabOverview"), glyph: "◈", panel: () => html`<${OverviewPanel} />` },
|
||||
{ id: "usage", name: t("app.tabUsage"), glyph: "$", panel: () => html`<${UsagePanel} />` },
|
||||
{ id: "health", name: t("app.tabSystem"), glyph: "+", panel: () => html`<${SystemPanel} />` },
|
||||
{ id: "semantic", name: t("app.tabSemantic"), glyph: "≈", panel: () => html`<${SemanticPanel} />` },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t("app.sectionConfigure"),
|
||||
tabs: [
|
||||
{ id: "tools", name: t("app.tabTools"), glyph: "▣", panel: () => html`<${ToolsPanel} />` },
|
||||
{ id: "permissions", name: t("app.tabPermissions"), glyph: "▎", panel: () => html`<${PermissionsPanel} />` },
|
||||
{ id: "mcp", name: t("app.tabMcp"), glyph: "M", panel: () => html`<${McpPanel} />` },
|
||||
{ id: "skills", name: t("app.tabSkills"), glyph: "S", panel: () => html`<${SkillsPanel} />` },
|
||||
{ id: "memory", name: t("app.tabMemory"), glyph: "·", panel: () => html`<${MemoryPanel} />` },
|
||||
{ id: "hooks", name: t("app.tabHooks"), glyph: "H", panel: () => html`<${HooksPanel} />` },
|
||||
{ id: "settings", name: t("app.tabSettings"), glyph: "⌘", panel: () => html`<${SettingsPanel} />` },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [activeId, setActiveId] = useState("chat");
|
||||
useLang();
|
||||
useEffect(() => { initLangFromServer(); }, []);
|
||||
const [activeId, setActiveId] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("rx.activeTab") ?? "chat";
|
||||
} catch {
|
||||
return "chat";
|
||||
}
|
||||
});
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("rx.sidebarCollapsed") === "1";
|
||||
@@ -73,7 +82,19 @@ function App() {
|
||||
/* private mode / disabled storage — ignore */
|
||||
}
|
||||
}, [sidebarCollapsed]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("rx.activeTab", activeId);
|
||||
} catch {
|
||||
/* private mode / disabled storage — ignore */
|
||||
}
|
||||
}, [activeId]);
|
||||
const TAB_SECTIONS = tabSections();
|
||||
const ALL_TABS = TAB_SECTIONS.flatMap((s) => s.tabs);
|
||||
const active = ALL_TABS.find((t) => t.id === activeId) ?? ALL_TABS[0];
|
||||
useEffect(() => {
|
||||
if (active.id !== activeId) setActiveId(active.id);
|
||||
}, [active.id, activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onNav = (ev) => {
|
||||
@@ -135,7 +156,7 @@ function App() {
|
||||
</div>
|
||||
<footer class="app-status">
|
||||
<span class="grow"></span>
|
||||
<span class="item">127.0.0.1 only · token-gated</span>
|
||||
<span class="item">${t("app.footer")}</span>
|
||||
</footer>
|
||||
</div>
|
||||
<${ToastStack} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { marked } from "marked";
|
||||
import { useState } from "preact/hooks";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
import {
|
||||
escapeHtml,
|
||||
hlLine,
|
||||
@@ -129,6 +130,7 @@ export function parseToolArgs(raw: string | null | undefined): Record<string, un
|
||||
}
|
||||
|
||||
export function ToolCard({ msg }: ToolCardProps) {
|
||||
useLang();
|
||||
const args = parseToolArgs(msg.toolArgs);
|
||||
const name = msg.toolName ?? "tool";
|
||||
// Reasonix's filesystem tools emit the path in args.path; MCP-bridged
|
||||
@@ -247,7 +249,7 @@ export function ToolCard({ msg }: ToolCardProps) {
|
||||
</div>
|
||||
${
|
||||
args
|
||||
? html`<details class="tool-card-args"><summary>arguments</summary><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></details>`
|
||||
? html`<details class="tool-card-args"><summary>${t("modal.arguments")}</summary><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></details>`
|
||||
: null
|
||||
}
|
||||
<pre class="tool-card-output">${msg.text}</pre>
|
||||
@@ -301,31 +303,33 @@ export function ModalCard({ accent, icon, title, subtitle, children }: ModalCard
|
||||
}
|
||||
|
||||
export function ShellModal({ modal, onResolve }: { modal: ShellModalSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const isBg = modal.shellKind === "run_background";
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#f87171"
|
||||
icon=${isBg ? "⏱" : "⚡"}
|
||||
title=${isBg ? "background process" : "shell command"}
|
||||
title=${isBg ? t("modal.shellBgTitle") : t("modal.shellTitle")}
|
||||
subtitle=${
|
||||
isBg ? "long-running — keeps running after approval" : "model wants to run a shell command"
|
||||
isBg ? t("modal.shellBgSubtitle") : t("modal.shellSubtitle")
|
||||
}
|
||||
>
|
||||
<div class="modal-cmd"><span class="modal-cmd-prompt">$</span> <code>${modal.command}</code></div>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("shell", "run_once")}>Run once</button>
|
||||
<button onClick=${() => onResolve("shell", "always_allow")}>Always allow "${modal.allowPrefix}"</button>
|
||||
<button class="danger" onClick=${() => onResolve("shell", "deny")}>Deny</button>
|
||||
<button class="primary" onClick=${() => onResolve("shell", "run_once")}>${t("modal.runOnce")}</button>
|
||||
<button onClick=${() => onResolve("shell", "always_allow")}>${t("modal.alwaysAllow", { prefix: modal.allowPrefix ?? "" })}</button>
|
||||
<button class="danger" onClick=${() => onResolve("shell", "deny")}>${t("modal.deny")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function ChoiceModal({ modal, onResolve }: { modal: ChoiceModalSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const [custom, setCustom] = useState("");
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
return html`
|
||||
<${ModalCard} accent="#f0abfc" icon="🔀" title="model wants you to pick" subtitle=${modal.question}>
|
||||
<${ModalCard} accent="#f0abfc" icon="🔀" title=${t("modal.choiceTitle")} subtitle=${modal.question}>
|
||||
${modal.options.map(
|
||||
(opt: ChoiceOption) => html`
|
||||
<button
|
||||
@@ -345,44 +349,45 @@ export function ChoiceModal({ modal, onResolve }: { modal: ChoiceModalSpec; onRe
|
||||
? html`
|
||||
<div class="modal-custom">
|
||||
<textarea
|
||||
placeholder="Type a free-form answer…"
|
||||
placeholder=${t("modal.typePlaceholder")}
|
||||
rows="2"
|
||||
value=${custom}
|
||||
onInput=${(e: Event) => setCustom((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("choice", { kind: "custom", text: custom })} disabled=${!custom.trim()}>Send</button>
|
||||
<button class="primary" onClick=${() => onResolve("choice", { kind: "custom", text: custom })} disabled=${!custom.trim()}>${t("modal.send")}</button>
|
||||
<button onClick=${() => {
|
||||
setShowCustom(false);
|
||||
setCustom("");
|
||||
}}>Back</button>
|
||||
}}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<button class="modal-choice-row" onClick=${() => setShowCustom(true)}>
|
||||
<span class="modal-choice-id">·</span>
|
||||
<span class="modal-choice-title">Type my own answer</span>
|
||||
<span class="modal-choice-summary">None of the above fits — write a free-form reply.</span>
|
||||
<span class="modal-choice-title">${t("modal.typeOwn")}</span>
|
||||
<span class="modal-choice-summary">${t("modal.typeOwnSummary")}</span>
|
||||
</button>
|
||||
`
|
||||
: null
|
||||
}
|
||||
<button class="modal-choice-row modal-choice-cancel" onClick=${() => onResolve("choice", { kind: "cancel" })}>
|
||||
<span class="modal-choice-id">×</span>
|
||||
<span class="modal-choice-title">Cancel</span>
|
||||
<span class="modal-choice-summary">Drop the question. Model will ask what you actually want.</span>
|
||||
<span class="modal-choice-title">${t("modal.cancel")}</span>
|
||||
<span class="modal-choice-summary">${t("modal.cancelSummary")}</span>
|
||||
</button>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function PlanModal({ modal, onResolve }: { modal: PlanModalSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [stage, setStage] = useState<"approve" | "refine" | null>(null);
|
||||
const send = () => onResolve("plan", stage, feedback);
|
||||
return html`
|
||||
<${ModalCard} accent="#67e8f9" icon="◆" title="plan submitted" subtitle="model proposed a plan; review then pick">
|
||||
<${ModalCard} accent="#67e8f9" icon="◆" title=${t("modal.planTitle")} subtitle=${t("modal.planSubtitle")}>
|
||||
<div class="md modal-plan-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body || "") }}></div>
|
||||
${
|
||||
stage
|
||||
@@ -390,26 +395,26 @@ export function PlanModal({ modal, onResolve }: { modal: PlanModalSpec; onResolv
|
||||
<textarea
|
||||
placeholder=${
|
||||
stage === "approve"
|
||||
? "Optional last instructions / answers to open questions (Enter to send blank)"
|
||||
: "What needs to change? Be specific."
|
||||
? t("modal.approveInstructions")
|
||||
: t("modal.refinePlaceholder")
|
||||
}
|
||||
rows="3"
|
||||
value=${feedback}
|
||||
onInput=${(e: Event) => setFeedback((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${send}>${stage === "approve" ? "Approve" : "Send refinement"}</button>
|
||||
<button class="primary" onClick=${send}>${stage === "approve" ? t("modal.approve") : t("modal.sendRefinement")}</button>
|
||||
<button onClick=${() => {
|
||||
setStage(null);
|
||||
setFeedback("");
|
||||
}}>Back</button>
|
||||
}}>${t("common.back")}</button>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => setStage("approve")}>Approve</button>
|
||||
<button onClick=${() => setStage("refine")}>Refine</button>
|
||||
<button class="danger" onClick=${() => onResolve("plan", "cancel")}>Cancel</button>
|
||||
<button class="primary" onClick=${() => setStage("approve")}>${t("modal.approve")}</button>
|
||||
<button onClick=${() => setStage("refine")}>${t("modal.refine")}</button>
|
||||
<button class="danger" onClick=${() => onResolve("plan", "cancel")}>${t("modal.cancel")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -489,6 +494,7 @@ function pairDiffRows(diff: DiffEntry[]): DiffPair[] {
|
||||
}
|
||||
|
||||
export function EditReviewModal({ modal, onResolve }: { modal: EditReviewSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const search = modal.search ?? "";
|
||||
const replace = modal.replace ?? "";
|
||||
const lang = langFromPath(modal.path);
|
||||
@@ -500,16 +506,16 @@ export function EditReviewModal({ modal, onResolve }: { modal: EditReviewSpec; o
|
||||
<${ModalCard}
|
||||
accent="#86efac"
|
||||
icon="◆"
|
||||
title="edit pending review"
|
||||
subtitle=${`${modal.path} · ${modal.remaining} of ${modal.total} blocks remaining`}
|
||||
title=${t("modal.editTitle")}
|
||||
subtitle=${t("modal.editSubtitle", { path: modal.path ?? "", remaining: modal.remaining, total: modal.total })}
|
||||
>
|
||||
<div class="edit-diff-wrap">
|
||||
<div class="edit-diff-head">
|
||||
<div class="edit-diff-side edit-diff-side-old">
|
||||
<span class="edit-diff-marker">−</span> before
|
||||
<span class="edit-diff-marker">−</span> ${t("modal.before")}
|
||||
</div>
|
||||
<div class="edit-diff-side edit-diff-side-new">
|
||||
<span class="edit-diff-marker">+</span> after
|
||||
<span class="edit-diff-marker">+</span> ${t("modal.after")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-diff-body">
|
||||
@@ -542,33 +548,35 @@ export function EditReviewModal({ modal, onResolve }: { modal: EditReviewSpec; o
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("edit-review", "apply")}>Apply (y)</button>
|
||||
<button onClick=${() => onResolve("edit-review", "reject")}>Reject (n)</button>
|
||||
<button onClick=${() => onResolve("edit-review", "apply-rest-of-turn")}>Apply rest (a)</button>
|
||||
<button onClick=${() => onResolve("edit-review", "flip-to-auto")}>Flip to AUTO (A)</button>
|
||||
<button class="primary" onClick=${() => onResolve("edit-review", "apply")}>${t("chat.confirmBtn")}</button>
|
||||
<button onClick=${() => onResolve("edit-review", "reject")}>${t("chat.rejectBtn")}</button>
|
||||
<button onClick=${() => onResolve("edit-review", "apply-rest-of-turn")}>${t("chat.applyRestBtn")}</button>
|
||||
<button onClick=${() => onResolve("edit-review", "flip-to-auto")}>${t("chat.flipAutoBtn")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function WorkspaceModal({ modal, onResolve }: { modal: WorkspaceSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#fbbf24"
|
||||
icon="◇"
|
||||
title="model wants to switch workspace"
|
||||
subtitle="every subsequent file / shell / memory tool resolves against the new root"
|
||||
title=${t("modal.workspaceTitle")}
|
||||
subtitle=${t("modal.workspaceSubtitle")}
|
||||
>
|
||||
<div class="modal-cmd"><span class="modal-cmd-prompt">→</span> <code>${modal.path}</code></div>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("workspace", "switch")}>Switch (Enter)</button>
|
||||
<button class="danger" onClick=${() => onResolve("workspace", "deny")}>Deny (Esc)</button>
|
||||
<button class="primary" onClick=${() => onResolve("workspace", "switch")}>${t("modal.switchBtn")}</button>
|
||||
<button class="danger" onClick=${() => onResolve("workspace", "deny")}>${t("modal.denyBtn")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function CheckpointModal({ modal, onResolve }: { modal: CheckpointSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const [reviseText, setReviseText] = useState("");
|
||||
const [staged, setStaged] = useState(false);
|
||||
const label = modal.title ? `${modal.stepId} · ${modal.title}` : modal.stepId;
|
||||
@@ -577,31 +585,31 @@ export function CheckpointModal({ modal, onResolve }: { modal: CheckpointSpec; o
|
||||
<${ModalCard}
|
||||
accent="#a5f3fc"
|
||||
icon="✓"
|
||||
title=${`step complete${counter}`}
|
||||
title=${t("modal.stepComplete", { counter })}
|
||||
subtitle=${label}
|
||||
>
|
||||
${
|
||||
staged
|
||||
? html`
|
||||
<textarea
|
||||
placeholder="What needs to change before the next step? Leave blank to just continue."
|
||||
placeholder=${t("modal.revisePlaceholder")}
|
||||
rows="3"
|
||||
value=${reviseText}
|
||||
onInput=${(e: Event) => setReviseText((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("checkpoint", "revise", reviseText)}>Send revision</button>
|
||||
<button class="primary" onClick=${() => onResolve("checkpoint", "revise", reviseText)}>${t("modal.sendRevision")}</button>
|
||||
<button onClick=${() => {
|
||||
setStaged(false);
|
||||
setReviseText("");
|
||||
}}>Back</button>
|
||||
}}>${t("common.back")}</button>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("checkpoint", "continue")}>Continue</button>
|
||||
<button onClick=${() => setStaged(true)}>Revise…</button>
|
||||
<button class="danger" onClick=${() => onResolve("checkpoint", "stop")}>Stop</button>
|
||||
<button class="primary" onClick=${() => onResolve("checkpoint", "continue")}>${t("modal.continueBtn")}</button>
|
||||
<button onClick=${() => setStaged(true)}>${t("modal.reviseBtn")}</button>
|
||||
<button class="danger" onClick=${() => onResolve("checkpoint", "stop")}>${t("modal.stopBtn")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -610,13 +618,14 @@ export function CheckpointModal({ modal, onResolve }: { modal: CheckpointSpec; o
|
||||
}
|
||||
|
||||
export function RevisionModal({ modal, onResolve }: { modal: RevisionSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const riskColor = (r: string | undefined) =>
|
||||
r === "high" ? "#f87171" : r === "med" ? "#fbbf24" : r === "low" ? "#86efac" : "#9ca3af";
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#c4b5fd"
|
||||
icon="✎"
|
||||
title="model proposed a plan revision"
|
||||
title=${t("modal.revisionTitle")}
|
||||
subtitle=${modal.summary || modal.reason}
|
||||
>
|
||||
<div class="modal-revise-reason">${modal.reason}</div>
|
||||
@@ -633,8 +642,8 @@ export function RevisionModal({ modal, onResolve }: { modal: RevisionSpec; onRes
|
||||
)}
|
||||
</ol>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("revision", "accept")}>Accept</button>
|
||||
<button class="danger" onClick=${() => onResolve("revision", "reject")}>Reject</button>
|
||||
<button class="primary" onClick=${() => onResolve("revision", "accept")}>${t("modal.accept")}</button>
|
||||
<button class="danger" onClick=${() => onResolve("revision", "reject")}>${t("modal.reject")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
export const en = {
|
||||
app: {
|
||||
sectionWorkspace: "workspace",
|
||||
sectionObserve: "observe",
|
||||
sectionConfigure: "configure",
|
||||
tabChat: "Chat",
|
||||
tabPlans: "Plans",
|
||||
tabSessions: "Sessions",
|
||||
tabOverview: "Overview",
|
||||
tabUsage: "Usage",
|
||||
tabSystem: "System",
|
||||
tabSemantic: "Semantic",
|
||||
tabTools: "Tools",
|
||||
tabPermissions: "Permissions",
|
||||
tabMcp: "MCP",
|
||||
tabSkills: "Skills",
|
||||
tabMemory: "Memory",
|
||||
tabHooks: "Hooks",
|
||||
tabSettings: "Settings",
|
||||
footer: "127.0.0.1 only · token-gated",
|
||||
},
|
||||
common: {
|
||||
loading: "loading…",
|
||||
loadingFailed: "{name} failed: {error}",
|
||||
back: "← back",
|
||||
save: "Save",
|
||||
remove: "remove",
|
||||
cancel: "Cancel",
|
||||
delete: "Delete",
|
||||
add: "Add",
|
||||
confirm: "Confirm",
|
||||
noData: "No {name} yet.",
|
||||
all: "all",
|
||||
yes: "yes",
|
||||
no: "no",
|
||||
on: "ON",
|
||||
off: "off",
|
||||
enabled: "enabled",
|
||||
disabled: "disabled",
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
loading: "loading settings…",
|
||||
saved: "saved: {fields}",
|
||||
sectionApi: "DeepSeek API",
|
||||
apiKey: "API key",
|
||||
notSet: "(not set)",
|
||||
replace: "replace",
|
||||
pasteKey: "paste a fresh sk-… token",
|
||||
saveKey: "Save key",
|
||||
baseUrl: "base url",
|
||||
baseUrlPlaceholder: "https://api.deepseek.com (default)",
|
||||
sectionDefaults: "Defaults",
|
||||
preset: "preset",
|
||||
presetAuto: "auto — flash → pro on hard turns",
|
||||
presetFlash: "flash — always flash, no auto-escalate",
|
||||
presetPro: "pro — always pro",
|
||||
appliesNextTurn: "applies next turn",
|
||||
effort: "effort",
|
||||
effortMax: "max (default — best)",
|
||||
effortHigh: "high (cheaper / faster)",
|
||||
webSearch: "web search",
|
||||
webSearchNote: "web_fetch + web_search tools",
|
||||
sectionRuntime: "Runtime",
|
||||
activeModel: "active model",
|
||||
editMode: "edit mode",
|
||||
editModeNote: "switch from the Chat tab header",
|
||||
sectionLanguage: "Language",
|
||||
language: "language",
|
||||
langEn: "English",
|
||||
langZhCn: "简体中文",
|
||||
},
|
||||
chat: {
|
||||
modeMirror: "TUI mirror",
|
||||
modeView: "session view",
|
||||
placeholder: "Type a prompt — Enter sends, Shift+Enter for a newline · / @ for pickers",
|
||||
placeholderBusy: "wait for the current turn to finish…",
|
||||
send: "Send",
|
||||
new: "New",
|
||||
clear: "Clear",
|
||||
newTitle: "/new — wipe conversation context (loop log + scrollback)",
|
||||
clearTitle: "/clear — wipe just visible scrollback (context kept)",
|
||||
noConversation: "No conversation yet. Send a prompt below to begin.",
|
||||
newConfirmBusy: "A turn is in flight. Abort and start a new conversation?",
|
||||
newConfirm: "Clear current conversation and start fresh?",
|
||||
newToast: "new conversation",
|
||||
clearToast: "scrollback cleared",
|
||||
newFailed: "/new failed: {error}",
|
||||
clearFailed: "/clear failed: {error}",
|
||||
eventStreamError: "event stream interrupted — reconnecting…",
|
||||
semanticBanner: "Semantic search isn't enabled for this project.",
|
||||
semanticBannerDesc: "Build the index once and the model can find code by meaning (\"where do we handle auth failures?\") instead of grep on exact strings.",
|
||||
semanticBannerBtn: "Build it →",
|
||||
semanticBannerDismiss: "dismiss (don't show again)",
|
||||
slashCommands: "slash commands",
|
||||
projectFiles: "project files",
|
||||
effortTitle: "reasoning_effort — applies next turn",
|
||||
effortMaxTitle: "max (default — best quality)",
|
||||
effortHighTitle: "high (cheaper / faster)",
|
||||
presetTitle: "preset — model commitment",
|
||||
presetAutoTitle: "auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)",
|
||||
presetFlashTitle: "flash — always flash; no auto-escalate. /pro still works for one-shot manual",
|
||||
presetProTitle: "pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work.",
|
||||
editGateTitle: "edit gate — Shift+Tab cycles in TUI",
|
||||
editReviewTitle: "review — both edits and non-allowlisted shell ask first",
|
||||
editAutoTitle: "auto — edits auto-apply, shell still asks",
|
||||
editYoloTitle: "yolo — edits AND shell auto-run, allowlist bypassed",
|
||||
railSession: "Session",
|
||||
railTurns: "turns",
|
||||
railPromptTok: "prompt tok",
|
||||
railCost: "cost",
|
||||
railCacheHit: "cache hit",
|
||||
railToolBudget: "Tool budget",
|
||||
railSpend: "spend",
|
||||
railActivePlan: "Active plan",
|
||||
railProgress: "progress",
|
||||
statusModel: "model",
|
||||
statusCtx: "ctx",
|
||||
statusCache: "cache",
|
||||
statusTurn: "turn",
|
||||
statusSession: "session",
|
||||
statusBalance: "balance",
|
||||
statusTurns: "{count} turn{s}",
|
||||
waitingStats: "· · · waiting for live stats",
|
||||
inflightPhase: "{phase}",
|
||||
inflightRunning: "running",
|
||||
inflightThinking: "thinking",
|
||||
inflightStreaming: "streaming",
|
||||
inflightWaiting: "waiting",
|
||||
inflightReasoning: "reasoning {count} ch",
|
||||
inflightOut: "out {count} ch",
|
||||
abortBtn: "Abort (Esc)",
|
||||
confirmBtn: "Apply (y)",
|
||||
rejectBtn: "Reject (n)",
|
||||
applyRestBtn: "Apply rest (a)",
|
||||
flipAutoBtn: "Flip to AUTO (A)",
|
||||
},
|
||||
overview: {
|
||||
loading: "loading overview…",
|
||||
failed: "overview failed: {error}",
|
||||
standaloneTitle: "Standalone mode",
|
||||
standaloneDesc: "Read-only disk view. Start /dashboard from inside reasonix code for live session state, MCP, and tools.",
|
||||
cockpit: "Cockpit",
|
||||
balance: "balance",
|
||||
tokens7d: "tokens · 7d",
|
||||
cacheHit: "cache hit",
|
||||
toolCalls24h: "tool calls · 24h",
|
||||
currentSession: "current session",
|
||||
noSession: "No live session — /dashboard from inside reasonix code to attach.",
|
||||
promptTok: "prompt tok",
|
||||
completionTok: "completion tok",
|
||||
cost: "cost",
|
||||
costTrend: "cost · 14 day",
|
||||
noUsageYet: "no usage yet",
|
||||
dayAvg: "/day avg",
|
||||
recentPlans: "recent plans",
|
||||
noPlans: "No plans yet — submit one with submit_plan.",
|
||||
toolActivity: "tool activity",
|
||||
noToolCalls: "No tool calls yet.",
|
||||
toolsLoaded: "tools loaded",
|
||||
mcpServers: "mcp servers",
|
||||
editMode: "edit mode",
|
||||
version: "Reasonix",
|
||||
workingDir: "Working directory",
|
||||
projectRoot: "project root",
|
||||
noPriorData: "no prior data",
|
||||
stable: "— stable",
|
||||
vsPrior: "{arrow} {pct}% vs prior",
|
||||
active: "active",
|
||||
allUp: "all up",
|
||||
yoloWarning: "all prompts bypassed",
|
||||
checking: "checking",
|
||||
latest: "latest",
|
||||
},
|
||||
usage: {
|
||||
loading: "loading usage…",
|
||||
failed: "usage failed: {error}",
|
||||
records: "{count} records",
|
||||
dailyUsage: "Daily usage",
|
||||
dailyMeta: "cost · cache saved · turns",
|
||||
noData: "No usage data yet — run a turn in reasonix chat / code / run and refresh.",
|
||||
windows: "Rolling windows",
|
||||
colTurns: "turns",
|
||||
colCacheHit: "cache hit",
|
||||
colCost: "cost (USD)",
|
||||
colCacheSaved: "cache saved",
|
||||
colVsClaude: "vs Claude",
|
||||
colSaved: "saved",
|
||||
mostUsed: "Most used models",
|
||||
colModel: "model",
|
||||
},
|
||||
sessions: {
|
||||
loading: "loading sessions…",
|
||||
failed: "sessions failed: {error}",
|
||||
noSessions: "No saved sessions yet.",
|
||||
filterPlaceholder: "filter sessions",
|
||||
msgs: "msgs",
|
||||
pickHint: "Pick a session on the left to read its transcript.",
|
||||
resumeTitle: "Resume in TUI",
|
||||
resumeDesc: "Mid-session swap requires a restart so the message log can rewind cleanly. Quit your current session, then run:",
|
||||
loadingTranscript: "loading transcript…",
|
||||
emptyTranscript: "empty transcript.",
|
||||
messages: "{count} message{s}",
|
||||
},
|
||||
tools: {
|
||||
loading: "loading tools…",
|
||||
failed: "tools failed: {error}",
|
||||
noTools: "No tools registered.",
|
||||
planMode: "plan mode — writes gated",
|
||||
colTool: "tool",
|
||||
colFlags: "flags",
|
||||
colDesc: "description",
|
||||
readOnly: "read-only",
|
||||
write: "write",
|
||||
flat: "flat",
|
||||
},
|
||||
permissions: {
|
||||
loading: "loading permissions…",
|
||||
failed: "permissions failed: {error}",
|
||||
yoloTitle: "YOLO mode",
|
||||
yoloDesc: "Every shell command auto-runs, allowlist bypassed. Switch back with /mode review in the TUI.",
|
||||
project: "project",
|
||||
builtin: "builtin",
|
||||
addPrefix: "add a prefix",
|
||||
addPlaceholder: "e.g. \"npm run build\" or \"deploy.sh\"",
|
||||
clearAll: "Clear all",
|
||||
alreadyIn: "{prefix} already in list",
|
||||
added: "added: {prefix}",
|
||||
removed: "removed: {prefix}",
|
||||
cleared: "cleared {count} entr{y}",
|
||||
removeConfirm: "Remove \"{prefix}\" from this project's allowlist?",
|
||||
clearConfirm: "Wipe every project allowlist entry? Builtin entries are unaffected.",
|
||||
projectAllowlist: "Project allowlist · {count}",
|
||||
nothingStored: "Nothing stored yet for this project.",
|
||||
colNum: "#",
|
||||
colPrefix: "prefix",
|
||||
builtinTitle: "Builtin · {count} · read-only",
|
||||
standaloneWarning: "Mutations require /dashboard from inside an active reasonix code session — standalone reasonix dashboard can't tell which project's allowlist to edit.",
|
||||
},
|
||||
mcp: {
|
||||
loading: "loading MCP…",
|
||||
servers: "MCP servers · {count} bridged",
|
||||
all: "all",
|
||||
live: "live",
|
||||
unbridged: "unbridged",
|
||||
specPlaceholder: "spec — e.g. fs=npx -y @modelcontextprotocol/...",
|
||||
saved: "saved",
|
||||
savedRestart: "saved — restart reasonix code to bridge this server",
|
||||
removed: "removed — restart to drop the live bridge",
|
||||
removeConfirm: "Remove MCP spec from config?\n\n{spec}",
|
||||
noServers: "No MCP servers in this session.",
|
||||
tools: "tools",
|
||||
inConfig: "in config · not loaded",
|
||||
unbridgedTitle: "unbridged · in config",
|
||||
removeBtn: "Remove",
|
||||
spec: "spec",
|
||||
whyUnbridged: "Why unbridged?",
|
||||
whyUnbridgedDesc: "This spec lives in your config.json but isn't bridged into the live session. MCP servers attach when reasonix code starts; the dashboard alone can't spawn the child process.",
|
||||
whyUnbridgedHint: "To activate: restart reasonix code, then refresh this dashboard.",
|
||||
pickHint: "Pick an MCP server on the left to inspect tools / resources / prompts.",
|
||||
toolsTitle: "Tools · {count}",
|
||||
resourcesTitle: "Resources · {count}",
|
||||
promptsTitle: "Prompts · {count}",
|
||||
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…",
|
||||
files: "memory · {count} files",
|
||||
exists: "exists",
|
||||
create: "create",
|
||||
noFiles: "No memory files yet.",
|
||||
pickHint: "Pick a memory file on the left.",
|
||||
pickDesc: "Project REASONIX.md is committable; global notes live in ~/.reasonix/memory/.",
|
||||
chars: "{count} chars",
|
||||
saved: "saved {scope}",
|
||||
reloadHint: "re-applied on next /new or session restart",
|
||||
},
|
||||
hooks: {
|
||||
loading: "loading hooks…",
|
||||
resolved: "resolved",
|
||||
eventMatrix: "Event matrix",
|
||||
matrixSub: "{scripts} script{s} × {events} event{s}",
|
||||
noHooks: "No hooks configured. Edit the JSON below to add some.",
|
||||
colScript: "script",
|
||||
noProject: "No active project — open /dashboard from reasonix code to edit project hooks.",
|
||||
saveReload: "Save + Reload",
|
||||
discard: "Discard changes",
|
||||
savedReloaded: "saved + reloaded {scope}",
|
||||
recentRuns: "Recent runs",
|
||||
noRuns: "No hook runs in the recent session log.",
|
||||
colWhen: "when",
|
||||
colPhase: "phase",
|
||||
colHook: "hook",
|
||||
colOutcome: "outcome",
|
||||
},
|
||||
skills: {
|
||||
loading: "loading skills…",
|
||||
filterPlaceholder: "filter skills",
|
||||
project: "project",
|
||||
global: "global",
|
||||
builtin: "builtin",
|
||||
newSkill: "new skill",
|
||||
noDescription: "(no description)",
|
||||
runs7d: "runs · 7d",
|
||||
pickHint: "Pick a skill on the left, or create a new one above.",
|
||||
readOnlyBuiltin: "read-only · builtin",
|
||||
builtinDesc: "Built-in skills ship with Reasonix; the model picks them up automatically. To customize, create a project- or global-scoped skill with the same name.",
|
||||
saved: "saved {scope}/{name}",
|
||||
deleteConfirm: "Delete skill {scope}/{name}?",
|
||||
reloadHint: "re-loaded on next /new or session restart",
|
||||
},
|
||||
system: {
|
||||
loading: "loading health…",
|
||||
failed: "health failed: {error}",
|
||||
healthChecks: "Health checks",
|
||||
version: "version",
|
||||
checking: "checking",
|
||||
latest: "● latest",
|
||||
outOfDate: "● out of date",
|
||||
versionPending: "version check pending",
|
||||
upToDate: "up to date",
|
||||
latestVer: "latest: {version}",
|
||||
sessions: "sessions",
|
||||
ok: "● ok",
|
||||
memory: "memory",
|
||||
semanticIndex: "semantic index",
|
||||
built: "● built",
|
||||
none: "— none",
|
||||
runIndex: "run reasonix index to build",
|
||||
usageLog: "usage log",
|
||||
backgroundJobs: "background jobs",
|
||||
noSession: "— no session",
|
||||
running: "{count} running",
|
||||
attachHint: "attach a session to see jobs",
|
||||
shellSpawn: "shell + spawn",
|
||||
paths: "paths",
|
||||
home: "home",
|
||||
sessionsPath: "sessions",
|
||||
memoryPath: "memory",
|
||||
semanticPath: "semantic",
|
||||
usagePath: "usage",
|
||||
},
|
||||
plans: {
|
||||
loading: "loading plans…",
|
||||
failed: "plans failed: {error}",
|
||||
noPlans: "No archived plans yet — run a turn that calls submit_plan and mark_step_complete.",
|
||||
filterPlaceholder: "filter plans",
|
||||
active: "active",
|
||||
done: "done",
|
||||
steps: "steps",
|
||||
pickHint: "Pick a plan on the left.",
|
||||
noTitle: "(no title)",
|
||||
stepTimeline: "Step timeline · {done} / {total}",
|
||||
step: "step {n}",
|
||||
},
|
||||
semantic: {
|
||||
codeRequired: "Semantic — code-mode required",
|
||||
indexBuilt: "index built",
|
||||
noIndex: "no index yet",
|
||||
ready: "ready",
|
||||
setupNeeded: "setup needed",
|
||||
installOllama: "Install Ollama",
|
||||
installOllamaDesc: "Reasonix doesn't run package managers for you. Install Ollama first, then come back:",
|
||||
macWindows: "macOS / Windows:",
|
||||
download: "download from ollama.com/download",
|
||||
linux: "Linux:",
|
||||
refreshHint: "Refresh after install — this panel will offer to start the daemon and pull {model}.",
|
||||
daemon: "Daemon",
|
||||
daemonDesc: "ollama is on your PATH but the HTTP daemon isn't reachable.",
|
||||
startDaemon: "Start daemon",
|
||||
runsOllama: "runs ollama serve detached",
|
||||
model: "Model",
|
||||
modelMissing: "{model} isn't installed yet.",
|
||||
modelSize: "~270 MB on first pull.",
|
||||
pulling: "pulling…",
|
||||
pullModel: "Pull {model}",
|
||||
indexStatus: "index status",
|
||||
builtStatus: "● built",
|
||||
chunks: "chunks",
|
||||
files: "files",
|
||||
dim: "dim",
|
||||
size: "size",
|
||||
lastBuild: "last build",
|
||||
runIndexHint: "Run an index to enable semantic_search.",
|
||||
reIndex: "Re-index",
|
||||
build: "Build",
|
||||
rebuild: "Rebuild",
|
||||
stop: "Stop",
|
||||
ollama: "ollama",
|
||||
binary: "binary",
|
||||
found: "found",
|
||||
missing: "missing",
|
||||
daemonStatus: "daemon",
|
||||
up: "up",
|
||||
down: "down",
|
||||
pulled: "pulled",
|
||||
indexConfig: "index config",
|
||||
reset: "reset",
|
||||
excludeDirs: "exclude dirs",
|
||||
excludeFiles: "exclude files",
|
||||
excludeExts: "exclude exts",
|
||||
excludePatterns: "exclude patterns",
|
||||
glob: "glob",
|
||||
respectGitignore: "respect .gitignore",
|
||||
maxFileBytes: "max file bytes",
|
||||
skipLarger: "skip files larger than ~{size} MiB",
|
||||
preview: "Preview",
|
||||
searchPlaceholder: "describe what to find — 'where do we handle abort signals'",
|
||||
searching: "searching…",
|
||||
results: "{count} result{s} · {ms}ms · {model}",
|
||||
noMatches: "No matches above the score threshold.",
|
||||
previewSummary: "Preview — would index {included} file(s), skip {skipped}",
|
||||
nothingSkipped: "nothing skipped — all walked files would be indexed.",
|
||||
firstIncluded: "first {count} included file(s)",
|
||||
job: "Job",
|
||||
phaseScan: "scanning files",
|
||||
phaseEmbed: "embedding chunks",
|
||||
phaseWrite: "writing index",
|
||||
phaseDone: "done",
|
||||
phaseError: "error",
|
||||
stopping: "stopping",
|
||||
scanned: "scanned {count}",
|
||||
changed: "changed {count}",
|
||||
skipped: "skipped {count}",
|
||||
chunksProgress: "{done} / {total} ({pct}%)",
|
||||
result: "result",
|
||||
added: "added {count}",
|
||||
removed: "removed {count}",
|
||||
failed: "failed {count}",
|
||||
skippedFiles: "{total} files ({details})",
|
||||
rebuildStarted: "rebuild started",
|
||||
incrementalStarted: "incremental index started",
|
||||
stopRequested: "stopping requested — current chunk batch will finish first",
|
||||
startingDaemon: "starting ollama daemon (15s timeout)…",
|
||||
daemonUp: "daemon is up",
|
||||
daemonTimeout: "daemon didn't come up in time — check ollama serve manually",
|
||||
pullingModel: "pulling {model} — this may take a few minutes on first install",
|
||||
savedConfig: "saved · {count} fields updated · re-run index to apply",
|
||||
runningPreview: "running dry walk against project root…",
|
||||
exclude: "exclude",
|
||||
},
|
||||
modal: {
|
||||
shellTitle: "shell command",
|
||||
shellBgTitle: "background process",
|
||||
shellSubtitle: "model wants to run a shell command",
|
||||
shellBgSubtitle: "long-running — keeps running after approval",
|
||||
runOnce: "Run once",
|
||||
alwaysAllow: "Always allow \"{prefix}\"",
|
||||
deny: "Deny",
|
||||
choiceTitle: "model wants you to pick",
|
||||
typeOwn: "Type my own answer",
|
||||
typeOwnSummary: "None of the above fits — write a free-form reply.",
|
||||
typePlaceholder: "Type a free-form answer…",
|
||||
send: "Send",
|
||||
cancel: "Cancel",
|
||||
cancelSummary: "Drop the question. Model will ask what you actually want.",
|
||||
planTitle: "plan submitted",
|
||||
planSubtitle: "model proposed a plan; review then pick",
|
||||
approveInstructions: "Optional last instructions / answers to open questions (Enter to send blank)",
|
||||
refinePlaceholder: "What needs to change? Be specific.",
|
||||
approve: "Approve",
|
||||
refine: "Refine",
|
||||
sendRefinement: "Send refinement",
|
||||
editTitle: "edit pending review",
|
||||
editSubtitle: "{path} · {remaining} of {total} blocks remaining",
|
||||
before: "before",
|
||||
after: "after",
|
||||
workspaceTitle: "model wants to switch workspace",
|
||||
workspaceSubtitle: "every subsequent file / shell / memory tool resolves against the new root",
|
||||
switchBtn: "Switch (Enter)",
|
||||
denyBtn: "Deny (Esc)",
|
||||
stepComplete: "step complete{counter}",
|
||||
continueBtn: "Continue",
|
||||
reviseBtn: "Revise…",
|
||||
stopBtn: "Stop",
|
||||
revisionTitle: "model proposed a plan revision",
|
||||
sendRevision: "Send revision",
|
||||
accept: "Accept",
|
||||
reject: "Reject",
|
||||
arguments: "arguments",
|
||||
revisePlaceholder: "What needs to change before the next step? Leave blank to just continue.",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createT } from "../lib/i18n.js";
|
||||
import { en } from "./en.js";
|
||||
import { zhCN } from "./zh-CN.js";
|
||||
|
||||
export { getLang, initLangFromServer, setLang, useLang, onLangChange } from "../lib/i18n.js";
|
||||
export type { DashboardLang } from "../lib/i18n.js";
|
||||
|
||||
export const t = createT({ en, "zh-CN": zhCN });
|
||||
@@ -0,0 +1,511 @@
|
||||
export const zhCN = {
|
||||
app: {
|
||||
sectionWorkspace: "工作区",
|
||||
sectionObserve: "监控",
|
||||
sectionConfigure: "配置",
|
||||
tabChat: "对话",
|
||||
tabPlans: "计划",
|
||||
tabSessions: "会话",
|
||||
tabOverview: "概览",
|
||||
tabUsage: "用量",
|
||||
tabSystem: "系统",
|
||||
tabSemantic: "语义",
|
||||
tabTools: "工具",
|
||||
tabPermissions: "权限",
|
||||
tabMcp: "MCP",
|
||||
tabSkills: "技能",
|
||||
tabMemory: "记忆",
|
||||
tabHooks: "钩子",
|
||||
tabSettings: "设置",
|
||||
footer: "仅 127.0.0.1 · Token 保护",
|
||||
},
|
||||
common: {
|
||||
loading: "加载中…",
|
||||
loadingFailed: "{name}失败:{error}",
|
||||
back: "← 返回",
|
||||
save: "保存",
|
||||
remove: "移除",
|
||||
cancel: "取消",
|
||||
delete: "删除",
|
||||
add: "添加",
|
||||
confirm: "确认",
|
||||
noData: "暂无{name}。",
|
||||
all: "全部",
|
||||
yes: "是",
|
||||
no: "否",
|
||||
on: "开启",
|
||||
off: "关闭",
|
||||
enabled: "已启用",
|
||||
disabled: "已禁用",
|
||||
},
|
||||
settings: {
|
||||
title: "设置",
|
||||
loading: "加载设置…",
|
||||
saved: "已保存:{fields}",
|
||||
sectionApi: "DeepSeek API",
|
||||
apiKey: "API 密钥",
|
||||
notSet: "(未设置)",
|
||||
replace: "替换",
|
||||
pasteKey: "粘贴新的 sk-… 令牌",
|
||||
saveKey: "保存密钥",
|
||||
baseUrl: "基础 URL",
|
||||
baseUrlPlaceholder: "https://api.deepseek.com(默认)",
|
||||
sectionDefaults: "默认设置",
|
||||
preset: "预设",
|
||||
presetAuto: "auto — flash 基线,困难时自动升级为 pro",
|
||||
presetFlash: "flash — 始终使用 flash,不自动升级",
|
||||
presetPro: "pro — 始终使用 pro",
|
||||
appliesNextTurn: "下一轮生效",
|
||||
effort: "推理强度",
|
||||
effortMax: "max(默认 — 最佳质量)",
|
||||
effortHigh: "high(更便宜 / 更快)",
|
||||
webSearch: "网页搜索",
|
||||
webSearchNote: "web_fetch + web_search 工具",
|
||||
sectionRuntime: "运行时",
|
||||
activeModel: "当前模型",
|
||||
editMode: "编辑模式",
|
||||
editModeNote: "在对话标签页头部切换",
|
||||
sectionLanguage: "语言",
|
||||
language: "语言",
|
||||
langEn: "English",
|
||||
langZhCn: "简体中文",
|
||||
},
|
||||
chat: {
|
||||
modeMirror: "TUI 镜像",
|
||||
modeView: "会话视图",
|
||||
placeholder: "输入提示词 — Enter 发送,Shift+Enter 换行 · / @ 打开选择器",
|
||||
placeholderBusy: "请等待当前轮次完成…",
|
||||
send: "发送",
|
||||
new: "新建",
|
||||
clear: "清除",
|
||||
newTitle: "/new — 清除对话上下文(循环日志 + 滚动回放)",
|
||||
clearTitle: "/clear — 仅清除可见的滚动回放(上下文保留)",
|
||||
noConversation: "暂无对话。在下方发送提示词开始。",
|
||||
newConfirmBusy: "有轮次正在执行。中止并开始新对话?",
|
||||
newConfirm: "清除当前对话并重新开始?",
|
||||
newToast: "新对话",
|
||||
clearToast: "滚动回放已清除",
|
||||
newFailed: "/new 失败:{error}",
|
||||
clearFailed: "/clear 失败:{error}",
|
||||
eventStreamError: "事件流中断 — 正在重连…",
|
||||
semanticBanner: "此项目未启用语义搜索。",
|
||||
semanticBannerDesc: "构建一次索引,模型即可按含义查找代码(“哪里处理认证失败?”),而不仅依赖精确字符串的 grep。",
|
||||
semanticBannerBtn: "构建 →",
|
||||
semanticBannerDismiss: "关闭(不再显示)",
|
||||
slashCommands: "斜杠命令",
|
||||
projectFiles: "项目文件",
|
||||
effortTitle: "reasoning_effort — 下一轮生效",
|
||||
effortMaxTitle: "max(默认 — 最佳质量)",
|
||||
effortHighTitle: "high(更便宜 / 更快)",
|
||||
presetTitle: "预设 — 模型承诺",
|
||||
presetAutoTitle: "auto — flash 基线;困难轮次自动升级为 pro(NEEDS_PRO / 失败阈值)",
|
||||
presetFlashTitle: "flash — 始终 flash;不自动升级。/pro 仍可用于一次性手动提升",
|
||||
presetProTitle: "pro — 始终 pro;约 3 倍 flash 成本(5/31 折扣)。锁定困难的架构工作。",
|
||||
editGateTitle: "编辑门控 — Shift+Tab 在 TUI 中循环",
|
||||
editReviewTitle: "review — 编辑和非允许列表的 shell 命令都会先询问",
|
||||
editAutoTitle: "auto — 编辑自动应用,shell 仍会询问",
|
||||
editYoloTitle: "yolo — 编辑和 shell 都自动运行,绕过允许列表",
|
||||
railSession: "会话",
|
||||
railTurns: "轮次",
|
||||
railPromptTok: "提示 tokens",
|
||||
railCost: "费用",
|
||||
railCacheHit: "缓存命中",
|
||||
railToolBudget: "工具预算",
|
||||
railSpend: "已用",
|
||||
railActivePlan: "活跃计划",
|
||||
railProgress: "进度",
|
||||
statusModel: "模型",
|
||||
statusCtx: "上下文",
|
||||
statusCache: "缓存",
|
||||
statusTurn: "轮次",
|
||||
statusSession: "会话",
|
||||
statusBalance: "余额",
|
||||
statusTurns: "{count} 轮",
|
||||
waitingStats: "· · · 等待实时统计",
|
||||
inflightPhase: "{phase}",
|
||||
inflightRunning: "运行中",
|
||||
inflightThinking: "思考中",
|
||||
inflightStreaming: "输出中",
|
||||
inflightWaiting: "等待中",
|
||||
inflightReasoning: "推理 {count} 字符",
|
||||
inflightOut: "输出 {count} 字符",
|
||||
abortBtn: "中止 (Esc)",
|
||||
confirmBtn: "应用 (y)",
|
||||
rejectBtn: "拒绝 (n)",
|
||||
applyRestBtn: "应用剩余 (a)",
|
||||
flipAutoBtn: "切换为 AUTO (A)",
|
||||
},
|
||||
overview: {
|
||||
loading: "加载概览…",
|
||||
failed: "概览失败:{error}",
|
||||
standaloneTitle: "独立模式",
|
||||
standaloneDesc: "只读磁盘视图。在 reasonix code 内启动 /dashboard 以获取实时会话状态、MCP 和工具。",
|
||||
cockpit: "驾驶舱",
|
||||
balance: "余额",
|
||||
tokens7d: "tokens · 7 天",
|
||||
cacheHit: "缓存命中",
|
||||
toolCalls24h: "工具调用 · 24 小时",
|
||||
currentSession: "当前会话",
|
||||
noSession: "无活跃会话 — 在 reasonix code 内执行 /dashboard 进行连接。",
|
||||
promptTok: "提示 tokens",
|
||||
completionTok: "补全 tokens",
|
||||
cost: "费用",
|
||||
costTrend: "费用 · 14 天",
|
||||
noUsageYet: "暂无用量",
|
||||
dayAvg: "/天 均值",
|
||||
recentPlans: "近期计划",
|
||||
noPlans: "暂无计划 — 使用 submit_plan 提交一个。",
|
||||
toolActivity: "工具活动",
|
||||
noToolCalls: "暂无工具调用。",
|
||||
toolsLoaded: "已加载工具",
|
||||
mcpServers: "MCP 服务器",
|
||||
editMode: "编辑模式",
|
||||
version: "Reasonix",
|
||||
workingDir: "工作目录",
|
||||
projectRoot: "项目根目录",
|
||||
noPriorData: "无历史数据",
|
||||
stable: "— 稳定",
|
||||
vsPrior: "{arrow} {pct}% 较上期",
|
||||
active: "活跃",
|
||||
allUp: "全部在线",
|
||||
yoloWarning: "所有提示被绕过",
|
||||
checking: "检查中",
|
||||
latest: "最新",
|
||||
},
|
||||
usage: {
|
||||
loading: "加载用量…",
|
||||
failed: "用量失败:{error}",
|
||||
records: "{count} 条记录",
|
||||
dailyUsage: "每日用量",
|
||||
dailyMeta: "费用 · 缓存节省 · 轮次",
|
||||
noData: "暂无用量数据 — 在 reasonix chat / code / run 中执行一轮,然后刷新。",
|
||||
windows: "滚动窗口",
|
||||
colTurns: "轮次",
|
||||
colCacheHit: "缓存命中",
|
||||
colCost: "费用 (USD)",
|
||||
colCacheSaved: "缓存节省",
|
||||
colVsClaude: "对比 Claude",
|
||||
colSaved: "节省",
|
||||
mostUsed: "最常用模型",
|
||||
colModel: "模型",
|
||||
},
|
||||
sessions: {
|
||||
loading: "加载会话…",
|
||||
failed: "会话失败:{error}",
|
||||
noSessions: "暂无已保存的会话。",
|
||||
filterPlaceholder: "筛选会话",
|
||||
msgs: "条消息",
|
||||
pickHint: "选择左侧的会话以查看其转录稿。",
|
||||
resumeTitle: "在 TUI 中恢复",
|
||||
resumeDesc: "会话中途切换需要重启,以便消息日志可以干净地回退。请退出当前会话,然后运行:",
|
||||
loadingTranscript: "加载转录稿…",
|
||||
emptyTranscript: "空的转录稿。",
|
||||
messages: "{count} 条消息",
|
||||
},
|
||||
tools: {
|
||||
loading: "加载工具…",
|
||||
failed: "工具失败:{error}",
|
||||
noTools: "未注册任何工具。",
|
||||
planMode: "计划模式 — 写入受限",
|
||||
colTool: "工具",
|
||||
colFlags: "标志",
|
||||
colDesc: "描述",
|
||||
readOnly: "只读",
|
||||
write: "写入",
|
||||
flat: "扁平",
|
||||
},
|
||||
permissions: {
|
||||
loading: "加载权限…",
|
||||
failed: "权限失败:{error}",
|
||||
yoloTitle: "YOLO 模式",
|
||||
yoloDesc: "所有 shell 命令自动运行,允许列表被绕过。在 TUI 中使用 /mode review 切换回来。",
|
||||
project: "项目",
|
||||
builtin: "内置",
|
||||
addPrefix: "添加前缀",
|
||||
addPlaceholder: "例如 \"npm run build\" 或 \"deploy.sh\"",
|
||||
clearAll: "清除全部",
|
||||
alreadyIn: "{prefix} 已在列表中",
|
||||
added: "已添加:{prefix}",
|
||||
removed: "已移除:{prefix}",
|
||||
cleared: "已清除 {count} 条",
|
||||
removeConfirm: "从此项目的允许列表中移除 \"{prefix}\"?",
|
||||
clearConfirm: "清除所有项目允许列表条目?内置条目不受影响。",
|
||||
projectAllowlist: "项目允许列表 · {count}",
|
||||
nothingStored: "此项目暂无存储的条目。",
|
||||
colNum: "#",
|
||||
colPrefix: "前缀",
|
||||
builtinTitle: "内置 · {count} · 只读",
|
||||
standaloneWarning: "修改操作需要在活跃的 reasonix code 会话内执行 /dashboard — 独立模式的 reasonix dashboard 无法确定要编辑哪个项目的允许列表。",
|
||||
},
|
||||
mcp: {
|
||||
loading: "加载 MCP…",
|
||||
servers: "MCP 服务器 · {count} 个已桥接",
|
||||
all: "全部",
|
||||
live: "在线",
|
||||
unbridged: "未桥接",
|
||||
specPlaceholder: "规格 — 例如 fs=npx -y @modelcontextprotocol/...",
|
||||
saved: "已保存",
|
||||
savedRestart: "已保存 — 重启 reasonix code 以桥接此服务器",
|
||||
removed: "已移除 — 重启以断开实时桥接",
|
||||
removeConfirm: "从配置中移除 MCP 规格?\n\n{spec}",
|
||||
noServers: "此会话中无 MCP 服务器。",
|
||||
tools: "个工具",
|
||||
inConfig: "在配置中 · 未加载",
|
||||
unbridgedTitle: "未桥接 · 在配置中",
|
||||
removeBtn: "移除",
|
||||
spec: "规格",
|
||||
whyUnbridged: "为什么未桥接?",
|
||||
whyUnbridgedDesc: "此规格存在于您的 config.json 中,但未桥接到实时会话。MCP 服务器在 reasonix code 启动时连接;仪表盘本身无法生成子进程。",
|
||||
whyUnbridgedHint: "激活方法:重启 reasonix code,然后刷新此仪表盘。",
|
||||
pickHint: "选择左侧的 MCP 服务器以检查工具 / 资源 / 提示。",
|
||||
toolsTitle: "工具 · {count}",
|
||||
resourcesTitle: "资源 · {count}",
|
||||
promptsTitle: "提示 · {count}",
|
||||
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: "加载记忆…",
|
||||
files: "记忆 · {count} 个文件",
|
||||
exists: "已存在",
|
||||
create: "创建",
|
||||
noFiles: "暂无记忆文件。",
|
||||
pickHint: "选择左侧的记忆文件。",
|
||||
pickDesc: "项目 REASONIX.md 可提交;全局笔记存储在 ~/.reasonix/memory/。",
|
||||
chars: "{count} 个字符",
|
||||
saved: "已保存 {scope}",
|
||||
reloadHint: "在下次 /new 或会话重启时重新加载",
|
||||
},
|
||||
hooks: {
|
||||
loading: "加载钩子…",
|
||||
resolved: "已解析",
|
||||
eventMatrix: "事件矩阵",
|
||||
matrixSub: "{scripts} 个脚本 × {events} 个事件",
|
||||
noHooks: "未配置钩子。编辑下方的 JSON 以添加。",
|
||||
colScript: "脚本",
|
||||
noProject: "无活跃项目 — 在 reasonix code 中打开 /dashboard 以编辑项目钩子。",
|
||||
saveReload: "保存并重载",
|
||||
discard: "放弃更改",
|
||||
savedReloaded: "已保存并重载 {scope}",
|
||||
recentRuns: "近期运行",
|
||||
noRuns: "近期会话日志中无钩子运行记录。",
|
||||
colWhen: "时间",
|
||||
colPhase: "阶段",
|
||||
colHook: "钩子",
|
||||
colOutcome: "结果",
|
||||
},
|
||||
skills: {
|
||||
loading: "加载技能…",
|
||||
filterPlaceholder: "筛选技能",
|
||||
project: "项目",
|
||||
global: "全局",
|
||||
builtin: "内置",
|
||||
newSkill: "新技能",
|
||||
noDescription: "(无描述)",
|
||||
runs7d: "次运行 · 7 天",
|
||||
pickHint: "选择左侧的技能,或在上方创建新技能。",
|
||||
readOnlyBuiltin: "只读 · 内置",
|
||||
builtinDesc: "内置技能随 Reasonix 一起发布;模型会自动识别。如需自定义,请创建同名的项目或全局技能。",
|
||||
saved: "已保存 {scope}/{name}",
|
||||
deleteConfirm: "删除技能 {scope}/{name}?",
|
||||
reloadHint: "在下次 /new 或会话重启时重新加载",
|
||||
},
|
||||
system: {
|
||||
loading: "加载健康状态…",
|
||||
failed: "健康检查失败:{error}",
|
||||
healthChecks: "健康检查",
|
||||
version: "版本",
|
||||
checking: "检查中",
|
||||
latest: "● 最新",
|
||||
outOfDate: "● 需要更新",
|
||||
versionPending: "版本检查中",
|
||||
upToDate: "已是最新",
|
||||
latestVer: "最新:{version}",
|
||||
sessions: "会话",
|
||||
ok: "● 正常",
|
||||
memory: "记忆",
|
||||
semanticIndex: "语义索引",
|
||||
built: "● 已构建",
|
||||
none: "— 无",
|
||||
runIndex: "运行 reasonix index 以构建",
|
||||
usageLog: "用量日志",
|
||||
backgroundJobs: "后台任务",
|
||||
noSession: "— 无会话",
|
||||
running: "{count} 个运行中",
|
||||
attachHint: "连接会话以查看任务",
|
||||
shellSpawn: "Shell + 生成",
|
||||
paths: "路径",
|
||||
home: "主目录",
|
||||
sessionsPath: "会话",
|
||||
memoryPath: "记忆",
|
||||
semanticPath: "语义",
|
||||
usagePath: "用量",
|
||||
},
|
||||
plans: {
|
||||
loading: "加载计划…",
|
||||
failed: "计划失败:{error}",
|
||||
noPlans: "暂无归档计划 — 运行调用 submit_plan 和 mark_step_complete 的轮次。",
|
||||
filterPlaceholder: "筛选计划",
|
||||
active: "进行中",
|
||||
done: "已完成",
|
||||
steps: "步骤",
|
||||
pickHint: "选择左侧的计划。",
|
||||
noTitle: "(无标题)",
|
||||
stepTimeline: "步骤时间线 · {done} / {total}",
|
||||
step: "步骤 {n}",
|
||||
},
|
||||
semantic: {
|
||||
codeRequired: "语义 — 需要代码模式",
|
||||
indexBuilt: "索引已构建",
|
||||
noIndex: "尚无索引",
|
||||
ready: "就绪",
|
||||
setupNeeded: "需要设置",
|
||||
installOllama: "安装 Ollama",
|
||||
installOllamaDesc: "Reasonix 不会为您运行包管理器。请先安装 Ollama,然后返回:",
|
||||
macWindows: "macOS / Windows:",
|
||||
download: "从 ollama.com/download 下载",
|
||||
linux: "Linux:",
|
||||
refreshHint: "安装后刷新 — 此面板将提供启动守护进程和拉取 {model} 的选项。",
|
||||
daemon: "守护进程",
|
||||
daemonDesc: "ollama 在您的 PATH 中,但 HTTP 守护进程不可达。",
|
||||
startDaemon: "启动守护进程",
|
||||
runsOllama: "以分离模式运行 ollama serve",
|
||||
model: "模型",
|
||||
modelMissing: "{model} 尚未安装。",
|
||||
modelSize: "首次拉取约 270 MB。",
|
||||
pulling: "拉取中…",
|
||||
pullModel: "拉取 {model}",
|
||||
indexStatus: "索引状态",
|
||||
builtStatus: "● 已构建",
|
||||
chunks: "分块",
|
||||
files: "文件",
|
||||
dim: "维度",
|
||||
size: "大小",
|
||||
lastBuild: "上次构建",
|
||||
runIndexHint: "运行索引以启用 semantic_search。",
|
||||
reIndex: "重建索引",
|
||||
build: "构建",
|
||||
rebuild: "完全重建",
|
||||
stop: "停止",
|
||||
ollama: "Ollama",
|
||||
binary: "二进制",
|
||||
found: "已找到",
|
||||
missing: "缺失",
|
||||
daemonStatus: "守护进程",
|
||||
up: "运行中",
|
||||
down: "已停止",
|
||||
pulled: "已拉取",
|
||||
indexConfig: "索引配置",
|
||||
reset: "重置",
|
||||
excludeDirs: "排除目录",
|
||||
excludeFiles: "排除文件",
|
||||
excludeExts: "排除扩展名",
|
||||
excludePatterns: "排除模式",
|
||||
glob: "glob",
|
||||
respectGitignore: "遵循 .gitignore",
|
||||
maxFileBytes: "最大文件字节数",
|
||||
skipLarger: "跳过大于 ~{size} MiB 的文件",
|
||||
preview: "预览",
|
||||
searchPlaceholder: "描述要查找的内容 — '哪里处理中止信号'",
|
||||
searching: "搜索中…",
|
||||
results: "{count} 个结果 · {ms}ms · {model}",
|
||||
noMatches: "没有超过分数阈值的匹配。",
|
||||
previewSummary: "预览 — 将索引 {included} 个文件,跳过 {skipped} 个",
|
||||
nothingSkipped: "无跳过 — 所有遍历的文件都将被索引。",
|
||||
firstIncluded: "前 {count} 个包含的文件",
|
||||
job: "任务",
|
||||
phaseScan: "扫描文件",
|
||||
phaseEmbed: "嵌入分块",
|
||||
phaseWrite: "写入索引",
|
||||
phaseDone: "完成",
|
||||
phaseError: "错误",
|
||||
stopping: "停止中",
|
||||
scanned: "已扫描 {count}",
|
||||
changed: "已变更 {count}",
|
||||
skipped: "已跳过 {count}",
|
||||
chunksProgress: "{done} / {total}({pct}%)",
|
||||
result: "结果",
|
||||
added: "已添加 {count}",
|
||||
removed: "已移除 {count}",
|
||||
failed: "失败 {count}",
|
||||
skippedFiles: "{total} 个文件({details})",
|
||||
rebuildStarted: "已启动完全重建",
|
||||
incrementalStarted: "已启动增量索引",
|
||||
stopRequested: "已请求停止 — 当前分块批次将首先完成",
|
||||
startingDaemon: "正在启动 ollama 守护进程(15 秒超时)…",
|
||||
daemonUp: "守护进程已启动",
|
||||
daemonTimeout: "守护进程未在规定时间内启动 — 请手动检查 ollama serve",
|
||||
pullingModel: "正在拉取 {model} — 首次安装可能需要几分钟",
|
||||
savedConfig: "已保存 · {count} 个字段已更新 · 重新运行索引以应用",
|
||||
runningPreview: "正在对项目根目录执行干运行…",
|
||||
exclude: "排除",
|
||||
},
|
||||
modal: {
|
||||
shellTitle: "Shell 命令",
|
||||
shellBgTitle: "后台进程",
|
||||
shellSubtitle: "模型想要运行一条 Shell 命令",
|
||||
shellBgSubtitle: "长时间运行 — 批准后继续运行",
|
||||
runOnce: "运行一次",
|
||||
alwaysAllow: "始终允许 \"{prefix}\"",
|
||||
deny: "拒绝",
|
||||
choiceTitle: "模型需要您选择",
|
||||
typeOwn: "输入自定义回答",
|
||||
typeOwnSummary: "以上选项都不合适 — 写一个自由格式的回复。",
|
||||
typePlaceholder: "输入自由格式的回答…",
|
||||
send: "发送",
|
||||
cancel: "取消",
|
||||
cancelSummary: "放弃此问题。模型将询问您实际想要什么。",
|
||||
planTitle: "计划已提交",
|
||||
planSubtitle: "模型提出了一项计划;请审阅后选择",
|
||||
approveInstructions: "可选的最后指示 / 对开放问题的回答(Enter 发送空白)",
|
||||
refinePlaceholder: "需要更改什么?请具体说明。",
|
||||
approve: "批准",
|
||||
refine: "优化",
|
||||
sendRefinement: "发送优化",
|
||||
editTitle: "编辑待审阅",
|
||||
editSubtitle: "{path} · {remaining} / {total} 个块剩余",
|
||||
before: "修改前",
|
||||
after: "修改后",
|
||||
workspaceTitle: "模型想要切换工作区",
|
||||
workspaceSubtitle: "后续所有文件 / Shell / 记忆工具将针对新根目录解析",
|
||||
switchBtn: "切换 (Enter)",
|
||||
denyBtn: "拒绝 (Esc)",
|
||||
stepComplete: "步骤完成{counter}",
|
||||
continueBtn: "继续",
|
||||
reviseBtn: "修订…",
|
||||
stopBtn: "停止",
|
||||
revisionTitle: "模型提出了计划修订",
|
||||
sendRevision: "发送修订",
|
||||
accept: "接受",
|
||||
reject: "拒绝",
|
||||
arguments: "参数",
|
||||
revisePlaceholder: "下一步之前需要更改什么?留空则直接继续。",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { TOKEN, api } from "./api.js";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type DashboardLang = "en" | "zh-CN";
|
||||
|
||||
// [dashboardCode, backendCode] — add new languages here.
|
||||
const LANG_REGISTRY: [DashboardLang, string][] = [
|
||||
["en", "EN"],
|
||||
["zh-CN", "zh-CN"],
|
||||
];
|
||||
|
||||
const SUPPORTED = new Set(LANG_REGISTRY.map(([d]) => d));
|
||||
const TO_BACKEND = new Map(LANG_REGISTRY);
|
||||
const FROM_BACKEND = new Map(LANG_REGISTRY.map(([d, b]) => [b, d]));
|
||||
|
||||
const STORAGE_KEY = "rx.lang";
|
||||
const EXPLICIT_KEY = "rx.langExplicit";
|
||||
const listeners: Listener[] = [];
|
||||
let currentLang: DashboardLang = loadFromStorage() ?? "en";
|
||||
|
||||
function loadFromStorage(): DashboardLang | null {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v !== null && SUPPORTED.has(v as DashboardLang)) return v as DashboardLang;
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExplicit(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(EXPLICIT_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markExplicit(): void {
|
||||
try {
|
||||
localStorage.setItem(EXPLICIT_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function toBackendLang(lang: DashboardLang): string {
|
||||
return TO_BACKEND.get(lang) ?? lang;
|
||||
}
|
||||
|
||||
function fromBackendLang(raw: string): DashboardLang {
|
||||
return (FROM_BACKEND.get(raw) as DashboardLang) ?? "en";
|
||||
}
|
||||
|
||||
/** Fetch lang from backend on startup. */
|
||||
export async function initLangFromServer(): Promise<void> {
|
||||
try {
|
||||
const stored = loadFromStorage();
|
||||
const res = await api<{ lang?: string }>("/settings");
|
||||
const serverLang = res.lang ? fromBackendLang(res.lang) : null;
|
||||
|
||||
if (!serverLang || stored === serverLang) return;
|
||||
|
||||
if (isExplicit() && stored) {
|
||||
// User explicitly chose a language — push to server.
|
||||
api("/settings", { method: "POST", body: { lang: toBackendLang(stored) } })
|
||||
.catch((err) => console.error("[reasonix dashboard] lang sync:", err));
|
||||
return;
|
||||
}
|
||||
|
||||
// No explicit choice yet (first visit or cleared storage) — adopt server value.
|
||||
currentLang = serverLang;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, serverLang);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const cb of listeners) cb();
|
||||
} catch {
|
||||
/* offline — keep localStorage value */
|
||||
}
|
||||
}
|
||||
|
||||
export function getLang(): DashboardLang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
export function setLang(lang: DashboardLang): void {
|
||||
if (!SUPPORTED.has(lang)) return;
|
||||
currentLang = lang;
|
||||
markExplicit();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const cb of listeners) cb();
|
||||
// keepalive ensures the request completes even during page unload (refresh).
|
||||
fetch(`/api/settings?token=${TOKEN}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Reasonix-Token": TOKEN },
|
||||
body: JSON.stringify({ lang: toBackendLang(lang) }),
|
||||
keepalive: true,
|
||||
}).catch((err) => console.error("[reasonix dashboard] lang persist:", err));
|
||||
}
|
||||
|
||||
export function onLangChange(cb: Listener): () => void {
|
||||
listeners.push(cb);
|
||||
return () => {
|
||||
const i = listeners.indexOf(cb);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function useLang(): DashboardLang {
|
||||
const [lang, setLangState] = useState<DashboardLang>(currentLang);
|
||||
useEffect(() => onLangChange(() => setLangState(currentLang)), []);
|
||||
return lang;
|
||||
}
|
||||
|
||||
type Nested = { [k: string]: string | Nested };
|
||||
|
||||
function get(translations: Nested | undefined, path: string): string | undefined {
|
||||
let val: string | Nested | undefined = translations;
|
||||
for (const part of path.split(".")) {
|
||||
if (val === undefined || typeof val === "string") return undefined;
|
||||
val = val[part];
|
||||
}
|
||||
return typeof val === "string" ? val : undefined;
|
||||
}
|
||||
|
||||
export function createT(translations: Record<string, Nested>) {
|
||||
return function t(path: string, params?: Record<string, string | number>): string {
|
||||
let val = get(translations[currentLang] ?? translations.en, path);
|
||||
if (val === undefined) val = get(translations.en, path);
|
||||
if (val === undefined) return path;
|
||||
if (!params) return val;
|
||||
let result = val;
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
result = result.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { MODE, TOKEN, api } from "../lib/api.js";
|
||||
import { appBus, showToast } from "../lib/bus.js";
|
||||
import { fmtUsd } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface StreamingState {
|
||||
id: string;
|
||||
@@ -100,6 +101,7 @@ interface SettingsPatch {
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
useLang();
|
||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
const [streaming, setStreaming] = useState<StreamingState | null>(null);
|
||||
const [activeTool, setActiveTool] = useState<ActiveToolState | null>(null);
|
||||
@@ -263,7 +265,7 @@ export function ChatPanel() {
|
||||
es.onerror = () => {
|
||||
// Auto-reconnect by default; surface a brief banner on persistent
|
||||
// failure but don't tear down — EventSource retries in the background.
|
||||
setError("event stream interrupted — reconnecting…");
|
||||
setError(t("chat.eventStreamError"));
|
||||
setTimeout(() => setError(null), 3000);
|
||||
};
|
||||
return () => es.close();
|
||||
@@ -298,8 +300,8 @@ export function ChatPanel() {
|
||||
|
||||
const newConversation = useCallback(async () => {
|
||||
if (busy) {
|
||||
if (!confirm("A turn is in flight. Abort and start a new conversation?")) return;
|
||||
} else if (messages.length > 0 && !confirm("Clear current conversation and start fresh?")) {
|
||||
if (!confirm(t("chat.newConfirmBusy"))) return;
|
||||
} else if (messages.length > 0 && !confirm(t("chat.newConfirm"))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -307,7 +309,7 @@ export function ChatPanel() {
|
||||
setMessages([]);
|
||||
setStreaming(null);
|
||||
setActiveTool(null);
|
||||
showToast("new conversation", "info");
|
||||
showToast(t("chat.newToast"), "info");
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const r = await api<MessagesResponse>("/messages");
|
||||
@@ -317,7 +319,7 @@ export function ChatPanel() {
|
||||
}
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
setError(`/new failed: ${(err as Error).message}`);
|
||||
setError(t("chat.newFailed", { error: (err as Error).message }));
|
||||
}
|
||||
}, [busy, messages.length]);
|
||||
|
||||
@@ -327,7 +329,7 @@ export function ChatPanel() {
|
||||
setMessages([]);
|
||||
setStreaming(null);
|
||||
setActiveTool(null);
|
||||
showToast("scrollback cleared", "info");
|
||||
showToast(t("chat.clearToast"), "info");
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const r = await api<MessagesResponse>("/messages");
|
||||
@@ -337,7 +339,7 @@ export function ChatPanel() {
|
||||
}
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
setError(`/clear failed: ${(err as Error).message}`);
|
||||
setError(t("chat.clearFailed", { error: (err as Error).message }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -443,7 +445,7 @@ export function ChatPanel() {
|
||||
);
|
||||
|
||||
if (bootError) {
|
||||
return html`<div class="notice err">chat unavailable: ${bootError}</div>`;
|
||||
return html`<div class="notice err">${t("common.loadingFailed", { name: "chat", error: bootError })}</div>`;
|
||||
}
|
||||
|
||||
/** Suppresses scroll listener during programmatic auto-snap so it doesn't re-arm shouldAutoScroll. */
|
||||
@@ -557,20 +559,20 @@ 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" ? "TUI mirror" : "session view"}</span>
|
||||
<span class="chip-f active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
|
||||
</div>
|
||||
<div class="header-pickers" style="margin-left:auto">
|
||||
${
|
||||
effort
|
||||
? html`
|
||||
<div class="mode-picker" title="reasoning_effort — applies next turn">
|
||||
<div class="mode-picker" title=${t("chat.effortTitle")}>
|
||||
${["high", "max"].map(
|
||||
(e) => html`
|
||||
<button
|
||||
key=${e}
|
||||
class="mode-btn ${effort === e ? "active accent" : ""}"
|
||||
onClick=${() => setSetting("reasoningEffort", e)}
|
||||
title=${e === "max" ? "max (default — best quality)" : "high (cheaper / faster)"}
|
||||
title=${e === "max" ? t("chat.effortMaxTitle") : t("chat.effortHighTitle")}
|
||||
>${e}</button>
|
||||
`,
|
||||
)}
|
||||
@@ -581,7 +583,7 @@ export function ChatPanel() {
|
||||
${
|
||||
preset
|
||||
? html`
|
||||
<div class="mode-picker" title="preset — model commitment">
|
||||
<div class="mode-picker" title=${t("chat.presetTitle")}>
|
||||
${(() => {
|
||||
// Anything that isn't one of the three new presets
|
||||
// (including legacy fast/smart/max from old configs)
|
||||
@@ -597,10 +599,10 @@ export function ChatPanel() {
|
||||
onClick=${() => setSetting("preset", p)}
|
||||
title=${
|
||||
p === "auto"
|
||||
? "auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)"
|
||||
? t("chat.presetAutoTitle")
|
||||
: p === "flash"
|
||||
? "flash — always flash; no auto-escalate. /pro still works for one-shot manual"
|
||||
: "pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work."
|
||||
? t("chat.presetFlashTitle")
|
||||
: t("chat.presetProTitle")
|
||||
}
|
||||
>${p}</button>
|
||||
`,
|
||||
@@ -613,7 +615,7 @@ export function ChatPanel() {
|
||||
${
|
||||
editMode
|
||||
? html`
|
||||
<div class="mode-picker" title="edit gate — Shift+Tab cycles in TUI">
|
||||
<div class="mode-picker" title=${t("chat.editGateTitle")}>
|
||||
${["review", "auto", "yolo"].map(
|
||||
(m) => html`
|
||||
<button
|
||||
@@ -622,10 +624,10 @@ export function ChatPanel() {
|
||||
onClick=${() => setEditMode(m)}
|
||||
title=${
|
||||
m === "review"
|
||||
? "review — both edits and non-allowlisted shell ask first"
|
||||
? t("chat.editReviewTitle")
|
||||
: m === "auto"
|
||||
? "auto — edits auto-apply, shell still asks"
|
||||
: "yolo — edits AND shell auto-run, allowlist bypassed"
|
||||
? t("chat.editAutoTitle")
|
||||
: t("chat.editYoloTitle")
|
||||
}
|
||||
>${m}</button>
|
||||
`,
|
||||
@@ -647,19 +649,19 @@ export function ChatPanel() {
|
||||
? html`<div class="chat-banner">
|
||||
<span class="chat-banner-icon">≈</span>
|
||||
<span class="chat-banner-text">
|
||||
<strong>Semantic search isn't enabled for this project.</strong>
|
||||
<strong>${t("chat.semanticBanner")}</strong>
|
||||
<span class="muted">
|
||||
Build the index once and the model can find code by meaning ("where do we handle auth failures?") instead of grep on exact strings.
|
||||
${t("chat.semanticBannerDesc")}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
class="primary"
|
||||
onClick=${() => appBus.dispatchEvent(new CustomEvent("navigate-tab", { detail: { tabId: "semantic" } }))}
|
||||
>Build it →</button>
|
||||
>${t("chat.semanticBannerBtn")}</button>
|
||||
<button
|
||||
class="chat-banner-close"
|
||||
onClick=${() => setSemanticBannerDismissed(true)}
|
||||
title="dismiss (don't show again)"
|
||||
title=${t("chat.semanticBannerDismiss")}
|
||||
>×</button>
|
||||
</div>`
|
||||
: null
|
||||
@@ -692,7 +694,7 @@ export function ChatPanel() {
|
||||
${
|
||||
allMessages.length === 0
|
||||
? html`<div class="chat-empty">
|
||||
No conversation yet. Send a prompt below to begin.
|
||||
${t("chat.noConversation")}
|
||||
</div>`
|
||||
: allMessages.map(
|
||||
(m) => html`
|
||||
@@ -711,7 +713,7 @@ export function ChatPanel() {
|
||||
popoverKind && popoverItems.length > 0
|
||||
? html`
|
||||
<div class="popover" style="position:absolute;bottom:calc(100% + 6px);left:0;width:380px;max-height:280px;overflow-y:auto;z-index:10">
|
||||
<div class="popover-h">${popoverKind === "slash" ? "slash commands" : "project files"}</div>
|
||||
<div class="popover-h">${popoverKind === "slash" ? t("chat.slashCommands") : t("chat.projectFiles")}</div>
|
||||
${popoverItems.map(
|
||||
(it, i) => html`
|
||||
<div
|
||||
@@ -733,7 +735,7 @@ export function ChatPanel() {
|
||||
: null
|
||||
}
|
||||
<textarea
|
||||
placeholder=${busy ? "wait for the current turn to finish…" : "Type a prompt — Enter sends, Shift+Enter for a newline · / @ for pickers"}
|
||||
placeholder=${busy ? t("chat.placeholderBusy") : t("chat.placeholder")}
|
||||
value=${input}
|
||||
onInput=${onInput}
|
||||
onKeyDown=${onKeyDown}
|
||||
@@ -746,10 +748,10 @@ export function ChatPanel() {
|
||||
class="primary"
|
||||
onClick=${send}
|
||||
disabled=${busy || !input.trim()}
|
||||
>Send</button>
|
||||
>${t("chat.send")}</button>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button onClick=${newConversation} title="/new — wipe conversation context (loop log + scrollback)">New</button>
|
||||
<button onClick=${clearScrollback} title="/clear — wipe just visible scrollback (context kept)">Clear</button>
|
||||
<button onClick=${newConversation} title=${t("chat.newTitle")}>${t("chat.new")}</button>
|
||||
<button onClick=${clearScrollback} title=${t("chat.clearTitle")}>${t("chat.clear")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -782,6 +784,7 @@ interface SideRailProps {
|
||||
}
|
||||
|
||||
function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
useLang();
|
||||
if (!stats && !activePlan) return html`<aside class="chat-rail"></aside>`;
|
||||
const cachePct = stats ? Math.round(stats.cacheHitRatio * 100) : 0;
|
||||
const cacheTone = cachePct >= 80 ? "ok" : cachePct >= 50 ? "" : "warn";
|
||||
@@ -795,12 +798,12 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
stats
|
||||
? html`
|
||||
<div class="rail-card">
|
||||
<div class="rh">Session</div>
|
||||
<div class="rail-kv"><span class="k">turns</span><span class="v">${stats.turns.toLocaleString()}</span></div>
|
||||
<div class="rail-kv"><span class="k">prompt tok</span><span class="v">${stats.lastPromptTokens.toLocaleString()}</span></div>
|
||||
<div class="rail-kv"><span class="k">cost</span><span class="v">${fmtUsd(stats.totalCostUsd)}</span></div>
|
||||
<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="progress-row" style="margin-top:8px">
|
||||
<span class="lbl">cache hit</span>
|
||||
<span class="lbl">${t("chat.railCacheHit")}</span>
|
||||
<div class=${`progress ${cacheTone}`}><div class="progress-fill" style=${`width:${cachePct}%`}></div></div>
|
||||
<span class="v">${cachePct}%</span>
|
||||
</div>
|
||||
@@ -812,9 +815,9 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
showBudget
|
||||
? html`
|
||||
<div class="rail-card">
|
||||
<div class="rh">Tool budget</div>
|
||||
<div class="rh">${t("chat.railToolBudget")}</div>
|
||||
<div class="progress-row">
|
||||
<span class="lbl">spend</span>
|
||||
<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>
|
||||
</div>
|
||||
@@ -827,6 +830,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
}
|
||||
|
||||
function ActivePlanCard({ plan }: { plan: RailPlan }) {
|
||||
useLang();
|
||||
const dots = [];
|
||||
for (let i = 0; i < plan.totalSteps; i++) {
|
||||
const done = i < plan.completedSteps;
|
||||
@@ -840,10 +844,10 @@ function ActivePlanCard({ plan }: { plan: RailPlan }) {
|
||||
}
|
||||
return html`
|
||||
<div class="rail-card">
|
||||
<div class="rh">Active plan</div>
|
||||
<div class="rh">${t("chat.railActivePlan")}</div>
|
||||
<div class="steps" style="margin-bottom:8px">${dots}</div>
|
||||
<div class="rail-kv"><span class="k" style="font-family:var(--font-sans);color:var(--fg-1);font-size:12.5px">${plan.title}</span></div>
|
||||
<div class="rail-kv"><span class="k">progress</span><span class="v">${plan.completedSteps} / ${plan.totalSteps}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("chat.railProgress")}</span><span class="v">${plan.completedSteps} / ${plan.totalSteps}</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -894,6 +898,7 @@ function InFlightRow({
|
||||
onAbort,
|
||||
tick: _tick,
|
||||
}: InFlightRowProps) {
|
||||
useLang();
|
||||
const elapsedMs = startedAt ? Date.now() - startedAt : 0;
|
||||
const elapsed = (elapsedMs / 1000).toFixed(1);
|
||||
const reasoningLen = streaming?.reasoning?.length ?? 0;
|
||||
@@ -901,12 +906,12 @@ function InFlightRow({
|
||||
/** Tool dispatch wins over text/reasoning — model is blocked on the tool, show that. */
|
||||
const toolSummary = summarizeActiveTool(activeTool);
|
||||
const phase = toolSummary
|
||||
? "running"
|
||||
? t("chat.inflightRunning")
|
||||
: reasoningLen > 0 && textLen === 0
|
||||
? "thinking"
|
||||
? t("chat.inflightThinking")
|
||||
: textLen > 0
|
||||
? "streaming"
|
||||
: "waiting";
|
||||
? t("chat.inflightStreaming")
|
||||
: t("chat.inflightWaiting");
|
||||
return html`
|
||||
<div class="chat-inflight">
|
||||
<span class="spinner"></span>
|
||||
@@ -926,9 +931,9 @@ function InFlightRow({
|
||||
? html`
|
||||
<span class="chat-inflight-sep">·</span>
|
||||
<span class="muted">
|
||||
${reasoningLen > 0 ? html`reasoning ${reasoningLen.toLocaleString()} ch` : null}
|
||||
${reasoningLen > 0 ? html`${t("chat.inflightReasoning", { count: reasoningLen.toLocaleString() })}` : null}
|
||||
${reasoningLen > 0 && textLen > 0 ? html`<span> · </span>` : null}
|
||||
${textLen > 0 ? html`out ${textLen.toLocaleString()} ch` : null}
|
||||
${textLen > 0 ? html`${t("chat.inflightOut", { count: textLen.toLocaleString() })}` : null}
|
||||
</span>
|
||||
`
|
||||
: null
|
||||
@@ -941,7 +946,7 @@ function InFlightRow({
|
||||
`
|
||||
: null
|
||||
}
|
||||
<button class="chat-inflight-abort" onClick=${onAbort}>Abort (Esc)</button>
|
||||
<button class="chat-inflight-abort" onClick=${onAbort}>${t("chat.abortBtn")}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -952,10 +957,11 @@ interface ChatStatusBarProps {
|
||||
}
|
||||
|
||||
function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
|
||||
useLang();
|
||||
if (!stats) {
|
||||
return html`
|
||||
<div class="chat-statusbar">
|
||||
<span class="muted">· · · waiting for live stats</span>
|
||||
<span class="muted">${t("chat.waitingStats")}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -965,38 +971,38 @@ function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
|
||||
return html`
|
||||
<div class="chat-statusbar">
|
||||
<span class="status-item">
|
||||
<span class="status-label">model</span>
|
||||
<span class="status-label">${t("chat.statusModel")}</span>
|
||||
<code>${model ?? "—"}</code>
|
||||
</span>
|
||||
<span class="status-item">
|
||||
<span class="status-label">ctx</span>
|
||||
<span class="status-label">${t("chat.statusCtx")}</span>
|
||||
<span class="status-bar-mini">
|
||||
<span class="status-bar-mini-fill" style=${`width: ${Math.min(100, ctxPct).toFixed(1)}%;`}></span>
|
||||
</span>
|
||||
<span class="muted">${stats.lastPromptTokens.toLocaleString()} / ${(stats.contextCapTokens / 1000).toFixed(0)}K</span>
|
||||
</span>
|
||||
<span class="status-item">
|
||||
<span class="status-label">cache</span>
|
||||
<span class="status-label">${t("chat.statusCache")}</span>
|
||||
<span class=${stats.cacheHitRatio >= 0.9 ? "status-ok" : stats.cacheHitRatio >= 0.6 ? "status-warn" : "status-err"}>
|
||||
${(stats.cacheHitRatio * 100).toFixed(1)}%
|
||||
</span>
|
||||
</span>
|
||||
<span class="status-item">
|
||||
<span class="status-label">turn</span>
|
||||
<span class="status-label">${t("chat.statusTurn")}</span>
|
||||
<code>${fmtUsd(stats.lastTurnCostUsd)}</code>
|
||||
</span>
|
||||
<span class="status-item">
|
||||
<span class="status-label">session</span>
|
||||
<span class="status-label">${t("chat.statusSession")}</span>
|
||||
<code>${fmtUsd(stats.totalCostUsd)}</code>
|
||||
<span class="muted" style="font-size: 10px;">
|
||||
(${stats.turns} turn${stats.turns === 1 ? "" : "s"})
|
||||
${t("chat.statusTurns", { count: stats.turns, s: stats.turns === 1 ? "" : "s" })}
|
||||
</span>
|
||||
</span>
|
||||
${
|
||||
balance
|
||||
? html`
|
||||
<span class="status-item">
|
||||
<span class="status-label">balance</span>
|
||||
<span class="status-label">${t("chat.statusBalance")}</span>
|
||||
<code>${balance.total_balance} ${balance.currency}</code>
|
||||
</span>
|
||||
`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface HookHandler {
|
||||
command?: string;
|
||||
@@ -61,6 +62,7 @@ interface HooksData {
|
||||
}
|
||||
|
||||
export function HooksPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<HooksData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
@@ -99,7 +101,7 @@ export function HooksPanel() {
|
||||
try {
|
||||
await api("/hooks/save", { method: "POST", body: { scope, hooks: parsed } });
|
||||
await api("/hooks/reload", { method: "POST", body: {} });
|
||||
setInfo(`saved + reloaded ${scope}`);
|
||||
setInfo(t("hooks.savedReloaded", { scope }));
|
||||
setTimeout(() => setInfo(null), 3000);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -112,7 +114,7 @@ export function HooksPanel() {
|
||||
);
|
||||
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading hooks…</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("hooks.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
|
||||
@@ -132,23 +134,22 @@ export function HooksPanel() {
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">resolved <span class="ct">${data.resolved.length}</span></span>
|
||||
<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>`)}
|
||||
</div>
|
||||
${info ? html`<div><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err">${error}</div>` : null}
|
||||
|
||||
${sectionH3("Event matrix", `${matrixRows.length} script${matrixRows.length === 1 ? "" : "s"} × ${events.length} event${events.length === 1 ? "" : "s"}`)}
|
||||
${
|
||||
${sectionH3(t("hooks.eventMatrix"), t("hooks.matrixSub", { scripts: matrixRows.length, s: matrixRows.length === 1 ? "" : "s", events: events.length }))}${
|
||||
matrixRows.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">
|
||||
No hooks configured. Edit the JSON below to add some.
|
||||
${t("hooks.noHooks")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="card" style="padding:10px 14px;overflow-x:auto">
|
||||
<div class="matrix" style=${`min-width:fit-content`}>
|
||||
<div class="row h" style=${`grid-template-columns:${gridCols}`}>
|
||||
<div>script</div>
|
||||
<div>${t("hooks.colScript")}</div>
|
||||
${events.map((ev) => html`<div>${ev}</div>`)}
|
||||
</div>
|
||||
${matrixRows.map(
|
||||
@@ -182,8 +183,7 @@ export function HooksPanel() {
|
||||
${
|
||||
scope === "project" && !meta.path
|
||||
? html`<div class="card" style="color:var(--fg-3)">
|
||||
No active project — open <code class="mono">/dashboard</code> from
|
||||
<code class="mono">reasonix code</code> to edit project hooks.
|
||||
${t("hooks.noProject")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="card">
|
||||
@@ -196,9 +196,9 @@ export function HooksPanel() {
|
||||
></textarea>
|
||||
<div style="display:flex;gap:6px;margin-top:8px">
|
||||
<button class="btn primary" disabled=${busy} onClick=${() => saveScope(scope)}>
|
||||
Save + Reload
|
||||
${t("hooks.saveReload")}
|
||||
</button>
|
||||
<button class="btn ghost" disabled=${busy} onClick=${load}>Discard changes</button>
|
||||
<button class="btn ghost" disabled=${busy} onClick=${load}>${t("hooks.discard")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -206,21 +206,21 @@ export function HooksPanel() {
|
||||
`;
|
||||
})}
|
||||
|
||||
${sectionH3("Recent runs", `${data.recentRuns?.length ?? 0}`)}
|
||||
${sectionH3(t("hooks.recentRuns"), `${data.recentRuns?.length ?? 0}`)}
|
||||
${
|
||||
!data.recentRuns || data.recentRuns.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">
|
||||
No hook runs in the recent session log.
|
||||
${t("hooks.noRuns")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="card" style="padding:0;overflow-x:auto">
|
||||
<table class="tbl" style="width:100%;font-family:var(--font-mono);font-size:11.5px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left;padding:8px 12px">when</th>
|
||||
<th style="text-align:left;padding:8px 12px">phase</th>
|
||||
<th style="text-align:left;padding:8px 12px">hook</th>
|
||||
<th style="text-align:left;padding:8px 12px">outcome</th>
|
||||
<th style="text-align:left;padding:8px 12px">${t("hooks.colWhen")}</th>
|
||||
<th style="text-align:left;padding:8px 12px">${t("hooks.colPhase")}</th>
|
||||
<th style="text-align:left;padding:8px 12px">${t("hooks.colHook")}</th>
|
||||
<th style="text-align:left;padding:8px 12px">${t("hooks.colOutcome")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -19,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;
|
||||
@@ -29,9 +84,10 @@ 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();
|
||||
const [data, setData] = useState<McpData | null>(null);
|
||||
const [specs, setSpecs] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -41,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 {
|
||||
@@ -62,9 +182,7 @@ export function McpPanel() {
|
||||
method: "POST",
|
||||
body: { spec: newSpec.trim() },
|
||||
});
|
||||
setInfo(
|
||||
r.requiresRestart ? "saved — restart `reasonix code` to bridge this server" : "saved",
|
||||
);
|
||||
setInfo(r.requiresRestart ? t("mcp.savedRestart") : t("mcp.saved"));
|
||||
setTimeout(() => setInfo(null), 4000);
|
||||
setNewSpec("");
|
||||
await load();
|
||||
@@ -77,11 +195,11 @@ export function McpPanel() {
|
||||
|
||||
const removeSpec = useCallback(
|
||||
async (spec: string) => {
|
||||
if (!confirm(`Remove MCP spec from config?\n\n${spec}`)) return;
|
||||
if (!confirm(t("mcp.removeConfirm", { spec }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api("/mcp/specs", { method: "DELETE", body: { spec } });
|
||||
setInfo("removed — restart to drop the live bridge");
|
||||
setInfo(t("mcp.removed"));
|
||||
setTimeout(() => setInfo(null), 4000);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -93,50 +211,105 @@ export function McpPanel() {
|
||||
[load],
|
||||
);
|
||||
|
||||
if (!data && !error) return html`<div class="card" style="color:var(--fg-3)">loading MCP…</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">
|
||||
<div class="sessions-list">
|
||||
<div class="ssl-h" style="font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
MCP servers · ${liveCount} bridged
|
||||
${t("mcp.servers", { count: liveCount })}
|
||||
</div>
|
||||
<div style="padding:8px 12px 4px">
|
||||
<div class="chips">
|
||||
<span class=${`chip-f ${filter === "all" ? "active" : ""}`} onClick=${() => setFilter("all")}>all <span class="ct">${liveCount + unbridgedCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "live" ? "active" : ""}`} onClick=${() => setFilter("live")}>live <span class="ct">${liveCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "unbridged" ? "active" : ""}`} onClick=${() => setFilter("unbridged")}>unbridged <span class="ct">${unbridgedCount}</span></span>
|
||||
<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='spec — e.g. fs=npx -y @modelcontextprotocol/...'
|
||||
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">
|
||||
No MCP servers in this session.
|
||||
${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(
|
||||
@@ -148,9 +321,9 @@ export function McpPanel() {
|
||||
setOpenUnbridged(null);
|
||||
}}
|
||||
>
|
||||
<span class="name">${s.label} <span class="pill ok">live</span></span>
|
||||
<span class="name">${s.label} <span class="pill ok">${t("mcp.live")}</span></span>
|
||||
<span class="preview">${specCommand(s.spec)}</span>
|
||||
<span class="meta"><span><span class="v">${fmtNum(s.toolCount)}</span> tools</span></span>
|
||||
<span class="meta"><span><span class="v">${fmtNum(s.toolCount)}</span> ${t("mcp.tools")}</span></span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
@@ -167,9 +340,9 @@ export function McpPanel() {
|
||||
setOpen(null);
|
||||
}}
|
||||
>
|
||||
<span class="name">${specLabel(spec)} <span class="pill">unbridged</span></span>
|
||||
<span class="name">${specLabel(spec)} <span class="pill">${t("mcp.unbridged")}</span></span>
|
||||
<span class="preview">${specCommand(spec)}</span>
|
||||
<span class="meta"><span class="dim">in config · not loaded</span></span>
|
||||
<span class="meta"><span class="dim">${t("mcp.inConfig")}</span></span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
@@ -180,48 +353,58 @@ 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">unbridged · in config</span></span>
|
||||
<span class="ws"><span class="pill">${t("mcp.unbridgedTitle")}</span></span>
|
||||
<span class="actions">
|
||||
<button class="btn" disabled=${busy} onClick=${() => removeSpec(openUnbridged)}
|
||||
style="border-color:var(--c-err);color:var(--c-err)">Remove</button>
|
||||
<button class="btn ghost" onClick=${() => setOpenUnbridged(null)}>← back</button>
|
||||
style="border-color:var(--c-err);color:var(--c-err)">${t("mcp.removeBtn")}</button>
|
||||
<button class="btn ghost" onClick=${() => setOpenUnbridged(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">spec</span></div>
|
||||
<div class="card-h"><span class="title">${t("mcp.spec")}</span></div>
|
||||
<code class="mono" style="font-size:11.5px;color:var(--fg-2);word-break:break-all">${openUnbridged}</code>
|
||||
</div>
|
||||
<div class="card accent-warn">
|
||||
<div class="card-h"><span class="title" style="color:var(--c-warn)">Why unbridged?</span></div>
|
||||
<div class="card-h"><span class="title" style="color:var(--c-warn)">${t("mcp.whyUnbridged")}</span></div>
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6">
|
||||
This spec lives in your <code class="mono">config.json</code> but isn't bridged into the live session.
|
||||
MCP servers attach when <code class="mono">reasonix code</code> starts; the dashboard alone can't
|
||||
spawn the child process.
|
||||
${t("mcp.whyUnbridgedDesc")}
|
||||
<div style="margin-top:10px;color:var(--fg-3);font-size:12px">
|
||||
To activate: restart <code class="mono">reasonix code</code>, then refresh this dashboard.
|
||||
${t("mcp.whyUnbridgedHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
Pick an MCP server on the left to inspect tools / resources / prompts.
|
||||
: 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>
|
||||
<span class="actions">
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>← back</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">spec</span></div>
|
||||
<div class="card-h"><span class="title">${t("mcp.spec")}</span></div>
|
||||
<code class="mono" style="font-size:11.5px;color:var(--fg-2)">${open.spec}</code>
|
||||
</div>
|
||||
|
||||
@@ -234,15 +417,15 @@ export function McpPanel() {
|
||||
}
|
||||
|
||||
<h3 style="margin:18px 0 6px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
Tools · ${open.tools.length}
|
||||
${t("mcp.toolsTitle", { count: open.tools.length })}
|
||||
</h3>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>name</th><th>description</th></tr></thead>
|
||||
<thead><tr><th>${t("mcp.colName")}</th><th>${t("mcp.colDesc")}</th></tr></thead>
|
||||
<tbody>
|
||||
${open.tools.map(
|
||||
(t) =>
|
||||
html`<tr><td><code class="mono">${t.name}</code></td><td class="dim">${t.description ?? ""}</td></tr>`,
|
||||
(tool) =>
|
||||
html`<tr><td><code class="mono">${tool.name}</code></td><td class="dim">${tool.description ?? ""}</td></tr>`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -252,11 +435,11 @@ export function McpPanel() {
|
||||
open.resources.length > 0
|
||||
? html`
|
||||
<h3 style="margin:18px 0 6px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
Resources · ${open.resources.length}
|
||||
${t("mcp.resourcesTitle", { count: open.resources.length })}
|
||||
</h3>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>name</th><th>uri</th></tr></thead>
|
||||
<thead><tr><th>${t("mcp.colName")}</th><th>${t("mcp.colUri")}</th></tr></thead>
|
||||
<tbody>
|
||||
${open.resources.map(
|
||||
(r) =>
|
||||
@@ -273,11 +456,11 @@ export function McpPanel() {
|
||||
open.prompts.length > 0
|
||||
? html`
|
||||
<h3 style="margin:18px 0 6px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
Prompts · ${open.prompts.length}
|
||||
${t("mcp.promptsTitle", { count: open.prompts.length })}
|
||||
</h3>
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>name</th><th>description</th></tr></thead>
|
||||
<thead><tr><th>${t("mcp.colName")}</th><th>${t("mcp.colDesc")}</th></tr></thead>
|
||||
<tbody>
|
||||
${open.prompts.map(
|
||||
(p) =>
|
||||
@@ -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
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtBytes, fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface MemoryFile {
|
||||
name: string;
|
||||
@@ -18,6 +19,7 @@ interface MemoryTree {
|
||||
type Scope = "project" | "global" | "project-mem";
|
||||
|
||||
export function MemoryPanel() {
|
||||
useLang();
|
||||
const [tree, setTree] = useState<MemoryTree | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<{ scope: Scope; name?: string } | null>(null);
|
||||
@@ -63,7 +65,7 @@ export function MemoryPanel() {
|
||||
? "/memory/project"
|
||||
: `/memory/${open.scope}/${encodeURIComponent(open.name ?? "")}`;
|
||||
await api(path, { method: "POST", body: { body } });
|
||||
setInfo(`saved ${open.scope}${open.name ? `/${open.name}` : ""}`);
|
||||
setInfo(t("memory.saved", { scope: open.scope + (open.name ? `/${open.name}` : "") }));
|
||||
setTimeout(() => setInfo(null), 3000);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -74,7 +76,7 @@ export function MemoryPanel() {
|
||||
}, [open, body, load]);
|
||||
|
||||
if (!tree && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading memory…</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("memory.loading")}</div>`;
|
||||
if (error && !tree) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!tree) return null;
|
||||
|
||||
@@ -95,15 +97,16 @@ export function MemoryPanel() {
|
||||
`;
|
||||
};
|
||||
|
||||
const totalFiles =
|
||||
(tree.project.path ? 1 : 0) +
|
||||
tree.global.files.length +
|
||||
tree.projectMem.files.length;
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
<div class="sessions-list">
|
||||
<div class="ssl-h" style="font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
memory · ${
|
||||
(tree.project.path ? 1 : 0) +
|
||||
tree.global.files.length +
|
||||
tree.projectMem.files.length
|
||||
} files
|
||||
${t("memory.files", { count: totalFiles })}
|
||||
</div>
|
||||
<div class="ssl-rows">
|
||||
${
|
||||
@@ -117,8 +120,8 @@ export function MemoryPanel() {
|
||||
REASONIX.md
|
||||
${
|
||||
tree.project.exists
|
||||
? html`<span class="pill ok">exists</span>`
|
||||
: html`<span class="pill">create</span>`
|
||||
? html`<span class="pill ok">${t("memory.exists")}</span>`
|
||||
: html`<span class="pill">${t("memory.create")}</span>`
|
||||
}
|
||||
</span>
|
||||
<span class="preview">${tree.project.path}</span>
|
||||
@@ -134,7 +137,7 @@ export function MemoryPanel() {
|
||||
tree.projectMem.files.length === 0 &&
|
||||
!tree.project.path)
|
||||
? html`<div style="color:var(--fg-3);padding:14px;font-size:12px">
|
||||
No memory files yet.
|
||||
${t("memory.noFiles")}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
@@ -145,10 +148,9 @@ export function MemoryPanel() {
|
||||
${
|
||||
open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
Pick a memory file on the left.
|
||||
${t("memory.pickHint")}
|
||||
<div style="margin-top:12px;font-size:11.5px">
|
||||
Project REASONIX.md is committable; global notes live in
|
||||
<code class="mono">~/.reasonix/memory/</code>.
|
||||
${t("memory.pickDesc")}
|
||||
</div>
|
||||
</div>`
|
||||
: html`
|
||||
@@ -156,10 +158,10 @@ export function MemoryPanel() {
|
||||
<span class="name">
|
||||
${open.scope}${open.name ? `/${open.name}` : ""}
|
||||
</span>
|
||||
<span class="ws">${body.length.toLocaleString()} chars</span>
|
||||
<span class="ws">${t("memory.chars", { count: body.length.toLocaleString() })}</span>
|
||||
<span class="actions">
|
||||
<button class="btn primary" disabled=${busy} onClick=${save}>Save</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>← back</button>
|
||||
<button class="btn primary" disabled=${busy} onClick=${save}>${t("common.save")}</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
${info ? html`<div style="margin-bottom:8px"><span class="pill ok">${info}</span></div>` : null}
|
||||
@@ -171,7 +173,7 @@ export function MemoryPanel() {
|
||||
disabled=${busy}
|
||||
></textarea>
|
||||
<div style="margin-top:8px;color:var(--fg-3);font-size:11.5px">
|
||||
re-applied on next <code class="mono">/new</code> or session restart
|
||||
${t("memory.reloadHint")}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fmtCompactNum, 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";
|
||||
|
||||
interface CockpitKpi {
|
||||
total: number;
|
||||
@@ -78,47 +79,47 @@ function kpi(label: string, value: unknown, delta?: unknown, deltaTone?: "up" |
|
||||
}
|
||||
|
||||
function deltaPctText(deltaPct: number | null): { text: string; tone: "up" | "down" | "flat" } {
|
||||
if (deltaPct === null) return { text: "no prior data", tone: "flat" };
|
||||
if (Math.abs(deltaPct) < 1) return { text: "— stable", tone: "flat" };
|
||||
if (deltaPct === null) return { text: t("overview.noPriorData"), tone: "flat" };
|
||||
if (Math.abs(deltaPct) < 1) return { text: t("overview.stable"), tone: "flat" };
|
||||
const arrow = deltaPct > 0 ? "▲" : "▼";
|
||||
return {
|
||||
text: `${arrow} ${Math.abs(deltaPct).toFixed(0)}% vs prior`,
|
||||
text: t("overview.vsPrior", { arrow, pct: Math.abs(deltaPct).toFixed(0) }),
|
||||
tone: deltaPct > 0 ? "up" : "down",
|
||||
};
|
||||
}
|
||||
|
||||
function deltaPpText(deltaPp: number | null): { text: string; tone: "up" | "down" | "flat" } {
|
||||
if (deltaPp === null) return { text: "no prior data", tone: "flat" };
|
||||
if (Math.abs(deltaPp) < 0.5) return { text: "— stable", tone: "flat" };
|
||||
if (deltaPp === null) return { text: t("overview.noPriorData"), tone: "flat" };
|
||||
if (Math.abs(deltaPp) < 0.5) return { text: t("overview.stable"), tone: "flat" };
|
||||
const arrow = deltaPp > 0 ? "▲" : "▼";
|
||||
return { text: `${arrow} ${Math.abs(deltaPp).toFixed(1)}pp`, tone: deltaPp > 0 ? "up" : "down" };
|
||||
}
|
||||
|
||||
function deltaCountText(delta: number | null): { text: string; tone: "up" | "down" | "flat" } {
|
||||
if (delta === null || delta === 0) return { text: "— stable", tone: "flat" };
|
||||
if (delta === null || delta === 0) return { text: t("overview.stable"), tone: "flat" };
|
||||
const arrow = delta > 0 ? "▲" : "▼";
|
||||
return { text: `${arrow} ${Math.abs(delta)}`, tone: delta > 0 ? "up" : "down" };
|
||||
}
|
||||
|
||||
function balanceKpi(c: CockpitData) {
|
||||
if (!c.balance) return kpi("balance", "—", "open in TUI", "flat");
|
||||
if (!c.balance) return kpi(t("overview.balance"), "—", "open in TUI", "flat");
|
||||
const symbol = c.balance.currency === "CNY" ? "¥" : c.balance.currency === "USD" ? "$" : "";
|
||||
return kpi("balance", `${symbol}${c.balance.total}`, c.balance.currency, "flat");
|
||||
return kpi(t("overview.balance"), `${symbol}${c.balance.total}`, c.balance.currency, "flat");
|
||||
}
|
||||
|
||||
function tokens7dKpi(c: CockpitData) {
|
||||
if (!c.tokens7d) return kpi("tokens · 7d", "—", "no usage yet", "flat");
|
||||
if (!c.tokens7d) return kpi(t("overview.tokens7d"), "—", t("overview.noUsageYet"), "flat");
|
||||
const d = deltaPctText(c.tokens7d.deltaPct);
|
||||
return kpi("tokens · 7d", fmtCompactNum(c.tokens7d.total), d.text, d.tone);
|
||||
return kpi(t("overview.tokens7d"), fmtCompactNum(c.tokens7d.total), d.text, d.tone);
|
||||
}
|
||||
|
||||
function cacheHitKpi(c: CockpitData) {
|
||||
if (!c.cacheHit7d) return kpi("cache hit", "—", "no usage yet", "flat");
|
||||
if (!c.cacheHit7d) return kpi(t("overview.cacheHit"), "—", t("overview.noUsageYet"), "flat");
|
||||
const pct = (c.cacheHit7d.ratio * 100).toFixed(0);
|
||||
const d = deltaPpText(c.cacheHit7d.deltaPp);
|
||||
return html`
|
||||
<div class="kpi cock-w-1">
|
||||
<div class="label">cache hit</div>
|
||||
<div class="label">${t("overview.cacheHit")}</div>
|
||||
<div class="value">${pct}<span class="unit">%</span></div>
|
||||
<div class=${`delta ${d.tone}`}>${d.text}</div>
|
||||
</div>
|
||||
@@ -126,18 +127,18 @@ function cacheHitKpi(c: CockpitData) {
|
||||
}
|
||||
|
||||
function toolCallsKpi(c: CockpitData) {
|
||||
if (!c.toolCalls24h) return kpi("tool calls · 24h", "—", "no events", "flat");
|
||||
if (!c.toolCalls24h) return kpi(t("overview.toolCalls24h"), "—", t("overview.noToolCalls"), "flat");
|
||||
const d = deltaCountText(c.toolCalls24h.delta);
|
||||
return kpi("tool calls · 24h", fmtNum(c.toolCalls24h.total), d.text, d.tone);
|
||||
return kpi(t("overview.toolCalls24h"), fmtNum(c.toolCalls24h.total), d.text, d.tone);
|
||||
}
|
||||
|
||||
function currentSessionBlock(c: CockpitData) {
|
||||
if (!c.currentSession) {
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">current session</span></div>
|
||||
<div class="ch"><span class="ttl">${t("overview.currentSession")}</span></div>
|
||||
<div style="color:var(--fg-3);font-size:12.5px;padding:8px 0">
|
||||
No live session — <code class="mono">/dashboard</code> from inside <code class="mono">reasonix code</code> to attach.
|
||||
${t("overview.noSession")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -145,14 +146,14 @@ function currentSessionBlock(c: CockpitData) {
|
||||
const s = c.currentSession;
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">current session</span></div>
|
||||
<div class="ch"><span class="ttl">${t("overview.currentSession")}</span></div>
|
||||
<div class="card accent-brand" style="margin:0 0 8px;background:transparent;border:none;padding:0">
|
||||
<div class="card-h"><span class="glyph">◆</span><span class="title">${s.id}</span><span class="meta">${s.turns} turn${s.turns === 1 ? "" : "s"}</span></div>
|
||||
</div>
|
||||
<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)">prompt tok</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)">completion tok</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)">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.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>
|
||||
</div>
|
||||
`;
|
||||
@@ -162,8 +163,8 @@ function costTrendSpark(c: CockpitData) {
|
||||
if (!c.costTrend14d || c.costTrend14d.length === 0) {
|
||||
return html`
|
||||
<div class="chart cock-w-2">
|
||||
<div class="chart-h"><span class="title">cost · 14 day</span></div>
|
||||
<div class="chart-v" style="color:var(--fg-4)">—<span class="unit">no usage yet</span></div>
|
||||
<div class="chart-h"><span class="title">${t("overview.costTrend")}</span></div>
|
||||
<div class="chart-v" style="color:var(--fg-4)">—<span class="unit">${t("overview.noUsageYet")}</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -183,8 +184,8 @@ function costTrendSpark(c: CockpitData) {
|
||||
const avg = total / days.length;
|
||||
return html`
|
||||
<div class="chart cock-w-2">
|
||||
<div class="chart-h"><span class="title">cost · 14 day</span></div>
|
||||
<div class="chart-v">${fmtUsd(avg)}<span class="unit">/day avg</span></div>
|
||||
<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-spark">
|
||||
<svg viewBox=${`0 0 ${w} ${h}`} preserveAspectRatio="none">
|
||||
<polyline fill="none" stroke="var(--c-brand)" stroke-width="1.5" points=${points} />
|
||||
@@ -198,10 +199,10 @@ function costTrendSpark(c: CockpitData) {
|
||||
function recentPlansRail(c: CockpitData) {
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">recent plans</span></div>
|
||||
<div class="ch"><span class="ttl">${t("overview.recentPlans")}</span></div>
|
||||
${
|
||||
!c.recentPlans || c.recentPlans.length === 0
|
||||
? html`<div style="color:var(--fg-3);font-size:12.5px;padding:8px 0">No plans yet — submit one with <code class="mono">submit_plan</code>.</div>`
|
||||
? html`<div style="color:var(--fg-3);font-size:12.5px;padding:8px 0">${t("overview.noPlans")}</div>`
|
||||
: c.recentPlans.map(
|
||||
(p) => html`
|
||||
<div class=${`rail-step ${p.status === "done" ? "done" : "active"}`}>
|
||||
@@ -219,10 +220,10 @@ function recentPlansRail(c: CockpitData) {
|
||||
function toolActivityFeed(c: CockpitData) {
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">tool activity</span></div>
|
||||
<div class="ch"><span class="ttl">${t("overview.toolActivity")}</span></div>
|
||||
${
|
||||
!c.toolActivity || c.toolActivity.length === 0
|
||||
? html`<div style="color:var(--fg-3);font-size:12.5px;padding:8px 0">No tool calls yet.</div>`
|
||||
? html`<div style="color:var(--fg-3);font-size:12.5px;padding:8px 0">${t("overview.noToolCalls")}</div>`
|
||||
: c.toolActivity.map(
|
||||
(r) => html`
|
||||
<div class=${`feed-row ${r.level}`}>
|
||||
@@ -238,10 +239,11 @@ function toolActivityFeed(c: CockpitData) {
|
||||
}
|
||||
|
||||
export function OverviewPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<OverviewData>("/overview", 2500);
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading overview…</div>`;
|
||||
if (error) return html`<div class="card accent-err">overview failed: ${error.message}</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("overview.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("overview.failed", { error: error.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const o = data;
|
||||
const c: CockpitData = o.cockpit ?? {
|
||||
@@ -256,7 +258,7 @@ export function OverviewPanel() {
|
||||
};
|
||||
const upToDate = o.latestVersion ? o.latestVersion === o.version : null;
|
||||
const versionDelta =
|
||||
upToDate === null ? "checking" : upToDate ? "latest" : `latest: ${o.latestVersion}`;
|
||||
upToDate === null ? t("overview.checking") : upToDate ? t("overview.latest") : `latest: ${o.latestVersion}`;
|
||||
const versionTone: "up" | "down" | "flat" = upToDate === false ? "down" : "flat";
|
||||
|
||||
return html`
|
||||
@@ -265,18 +267,17 @@ export function OverviewPanel() {
|
||||
o.mode === "standalone"
|
||||
? html`<div class="card accent-warn">
|
||||
<div class="card-h">
|
||||
<span class="title" style="color:var(--c-warn)">Standalone mode</span>
|
||||
<span class="title" style="color:var(--c-warn)">${t("overview.standaloneTitle")}</span>
|
||||
</div>
|
||||
<div class="card-b">
|
||||
Read-only disk view. Start <code class="mono">/dashboard</code> from inside
|
||||
<code class="mono">reasonix code</code> for live session state, MCP, and tools.
|
||||
${t("overview.standaloneDesc")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
<h3 style="margin:0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
Cockpit
|
||||
${t("overview.cockpit")}
|
||||
</h3>
|
||||
<div class="cockpit">
|
||||
${balanceKpi(c)}
|
||||
@@ -290,17 +291,17 @@ export function OverviewPanel() {
|
||||
${recentPlansRail(c)}
|
||||
${toolActivityFeed(c)}
|
||||
|
||||
${kpi("tools loaded", fmtNum(o.toolCount), o.toolCount ? "active" : "—", "flat")}
|
||||
${kpi("mcp servers", fmtNum(o.mcpServerCount), o.mcpServerCount ? "all up" : "—", o.mcpServerCount ? "up" : "flat")}
|
||||
${kpi("edit mode", o.editMode ?? "—", o.editMode === "yolo" ? "all prompts bypassed" : null, o.editMode === "yolo" ? "down" : "flat")}
|
||||
${kpi("Reasonix", o.version ?? "—", versionDelta, versionTone)}
|
||||
${kpi(t("overview.toolsLoaded"), fmtNum(o.toolCount), o.toolCount ? t("overview.active") : "—", "flat")}
|
||||
${kpi(t("overview.mcpServers"), fmtNum(o.mcpServerCount), o.mcpServerCount ? t("overview.allUp") : "—", o.mcpServerCount ? "up" : "flat")}
|
||||
${kpi(t("overview.editMode"), o.editMode ?? "—", o.editMode === "yolo" ? t("overview.yoloWarning") : null, o.editMode === "yolo" ? "down" : "flat")}
|
||||
${kpi(t("overview.version"), o.version ?? "—", versionDelta, versionTone)}
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
Working directory
|
||||
${t("overview.workingDir")}
|
||||
</h3>
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">project root</span></div>
|
||||
<div class="card-h"><span class="title">${t("overview.projectRoot")}</span></div>
|
||||
<code class="mono" style="color:var(--fg-2);font-size:12px">${o.cwd ?? "—"}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface PermissionsData {
|
||||
editMode?: string;
|
||||
@@ -29,6 +30,7 @@ function groupByVerb(list: string[]): [string, string[]][] {
|
||||
}
|
||||
|
||||
export function PermissionsPanel() {
|
||||
useLang();
|
||||
const { data, error, loading, refresh } = usePoll<PermissionsData>("/permissions", 5000);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -44,8 +46,8 @@ export function PermissionsPanel() {
|
||||
method: "POST",
|
||||
body: { prefix },
|
||||
});
|
||||
if (res.alreadyPresent) setFeedback({ kind: "info", text: `${prefix} already in list` });
|
||||
else setFeedback({ kind: "ok", text: `added: ${prefix}` });
|
||||
if (res.alreadyPresent) setFeedback({ kind: "info", text: t("permissions.alreadyIn", { prefix }) });
|
||||
else setFeedback({ kind: "ok", text: t("permissions.added", { prefix }) });
|
||||
setDraft("");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
@@ -57,12 +59,12 @@ export function PermissionsPanel() {
|
||||
|
||||
const remove = useCallback(
|
||||
async (prefix: string) => {
|
||||
if (!confirm(`Remove "${prefix}" from this project's allowlist?`)) return;
|
||||
if (!confirm(t("permissions.removeConfirm", { prefix }))) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
await api("/permissions", { method: "DELETE", body: { prefix } });
|
||||
setFeedback({ kind: "ok", text: `removed: ${prefix}` });
|
||||
setFeedback({ kind: "ok", text: t("permissions.removed", { prefix }) });
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setFeedback({ kind: "err", text: (err as Error).message });
|
||||
@@ -74,7 +76,7 @@ export function PermissionsPanel() {
|
||||
);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
if (!confirm("Wipe every project allowlist entry? Builtin entries are unaffected.")) return;
|
||||
if (!confirm(t("permissions.clearConfirm"))) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
@@ -84,7 +86,7 @@ export function PermissionsPanel() {
|
||||
});
|
||||
setFeedback({
|
||||
kind: "ok",
|
||||
text: `cleared ${res.dropped} entr${res.dropped === 1 ? "y" : "ies"}`,
|
||||
text: t("permissions.cleared", { count: res.dropped, y: res.dropped === 1 ? "y" : "ies" }),
|
||||
});
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
@@ -95,8 +97,8 @@ export function PermissionsPanel() {
|
||||
}, [refresh]);
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading permissions…</div>`;
|
||||
if (error) return html`<div class="card accent-err">permissions failed: ${error.message}</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("permissions.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "permissions", error: error.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const p = data;
|
||||
|
||||
@@ -111,18 +113,17 @@ export function PermissionsPanel() {
|
||||
${
|
||||
p.editMode === "yolo"
|
||||
? html`<div class="card accent-warn">
|
||||
<div class="card-h"><span class="title" style="color:var(--c-warn)">YOLO mode</span></div>
|
||||
<div class="card-h"><span class="title" style="color:var(--c-warn)">${t("permissions.yoloTitle")}</span></div>
|
||||
<div class="card-b">
|
||||
Every shell command auto-runs, allowlist bypassed.
|
||||
Switch back with <code class="mono">/mode review</code> in the TUI.
|
||||
${t("permissions.yoloDesc")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip-f active">project <span class="ct">${p.project.length}</span></span>
|
||||
<span class="chip-f">builtin <span class="ct">${p.builtin.length}</span></span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
${
|
||||
@@ -130,13 +131,13 @@ export function PermissionsPanel() {
|
||||
? html`
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="title">add a prefix</span>
|
||||
<span class="title">${t("permissions.addPrefix")}</span>
|
||||
<span class="meta">${p.currentCwd}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder='e.g. "npm run build" or "deploy.sh"'
|
||||
placeholder=${t("permissions.addPlaceholder")}
|
||||
value=${draft}
|
||||
onInput=${(e: Event) => setDraft((e.target as HTMLInputElement).value)}
|
||||
onKeyDown=${(e: KeyboardEvent) => {
|
||||
@@ -145,12 +146,12 @@ export function PermissionsPanel() {
|
||||
disabled=${busy}
|
||||
style="flex:1"
|
||||
/>
|
||||
<button class="primary" onClick=${add} disabled=${busy || !draft.trim()}>Add</button>
|
||||
<button class="primary" onClick=${add} disabled=${busy || !draft.trim()}>${t("common.add")}</button>
|
||||
<button
|
||||
class="danger"
|
||||
onClick=${clearAll}
|
||||
disabled=${busy || p.project.length === 0}
|
||||
>Clear all</button>
|
||||
>${t("permissions.clearAll")}</button>
|
||||
</div>
|
||||
${feedbackPill ? html`<div style="margin-top:8px">${feedbackPill}</div>` : null}
|
||||
</div>
|
||||
@@ -158,27 +159,25 @@ export function PermissionsPanel() {
|
||||
: html`
|
||||
<div class="card accent-warn">
|
||||
<div class="card-b">
|
||||
Mutations require <code class="mono">/dashboard</code> from inside an active
|
||||
<code class="mono">reasonix code</code> session — standalone
|
||||
<code class="mono">reasonix dashboard</code> can't tell which project's allowlist to edit.
|
||||
${t("permissions.standaloneWarning")}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
<h3 style="margin:6px 0 0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
Project allowlist · ${p.project.length}
|
||||
${t("permissions.projectAllowlist", { count: p.project.length })}
|
||||
</h3>
|
||||
${
|
||||
p.project.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">Nothing stored yet for this project.</div>`
|
||||
? html`<div class="card" style="color:var(--fg-3)">${t("permissions.nothingStored")}</div>`
|
||||
: html`
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:48px">#</th>
|
||||
<th>prefix</th>
|
||||
<th style="width:48px">${t("permissions.colNum")}</th>
|
||||
<th>${t("permissions.colPrefix")}</th>
|
||||
<th style="width:120px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -195,7 +194,7 @@ export function PermissionsPanel() {
|
||||
class="danger"
|
||||
onClick=${() => remove(prefix)}
|
||||
disabled=${busy}
|
||||
>remove</button>`
|
||||
>${t("common.remove")}</button>`
|
||||
: null
|
||||
}
|
||||
</td>
|
||||
@@ -209,7 +208,7 @@ export function PermissionsPanel() {
|
||||
}
|
||||
|
||||
<h3 style="margin:6px 0 0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
Builtin · ${p.builtin.length} · read-only
|
||||
${t("permissions.builtinTitle", { count: p.builtin.length })}
|
||||
</h3>
|
||||
<div class="card" style="font-family:var(--font-mono);font-size:11.5px;line-height:1.8">
|
||||
${groupByVerb(p.builtin).map(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "preact/hooks";
|
||||
import { fmtPct, fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface PlanStep {
|
||||
id: string;
|
||||
@@ -26,25 +27,25 @@ interface PlansData {
|
||||
}
|
||||
|
||||
function statusPill(p: ArchivedPlan) {
|
||||
if (p.completionRatio >= 1) return html`<span class="pill ok">done</span>`;
|
||||
if (p.completionRatio > 0) return html`<span class="pill info">active</span>`;
|
||||
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>`;
|
||||
}
|
||||
|
||||
export function PlansPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<PlansData>("/plans", 8000);
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading plans…</div>`;
|
||||
if (error) return html`<div class="card accent-err">plans failed: ${error.message}</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("plans.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "plans", error: error.message })}</div>`;
|
||||
const plans = data?.plans ?? [];
|
||||
|
||||
if (plans.length === 0)
|
||||
return html`<div class="card" style="color:var(--fg-3)">
|
||||
No archived plans yet — run a turn that calls <code class="mono">submit_plan</code>
|
||||
and <code class="mono">mark_step_complete</code>.
|
||||
${t("plans.noPlans")}
|
||||
</div>`;
|
||||
|
||||
const filtered = filter.trim()
|
||||
@@ -63,20 +64,20 @@ export function PlansPanel() {
|
||||
<div class="ssl-h">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="filter plans"
|
||||
placeholder=${t("plans.filterPlaceholder")}
|
||||
value=${filter}
|
||||
onInput=${(e: Event) => setFilter((e.target as HTMLInputElement).value)}
|
||||
style="flex:1"
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">all <span class="ct">${plans.length}</span></span>
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${plans.length}</span></span>
|
||||
<span class="chip-f">
|
||||
active
|
||||
${t("plans.active")}
|
||||
<span class="ct">${plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1).length}</span>
|
||||
</span>
|
||||
<span class="chip-f">
|
||||
done <span class="ct">${plans.filter((p) => p.completionRatio >= 1).length}</span>
|
||||
${t("plans.done")} <span class="ct">${plans.filter((p) => p.completionRatio >= 1).length}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ssl-rows">
|
||||
@@ -92,7 +93,7 @@ export function PlansPanel() {
|
||||
: null
|
||||
}
|
||||
<span class="meta">
|
||||
<span><span class="v">${p.totalSteps}</span> steps</span>
|
||||
<span><span class="v">${p.totalSteps}</span> ${t("plans.steps")}</span>
|
||||
<span><span class="v">${p.completedSteps} / ${p.totalSteps}</span> · ${fmtPct(p.completionRatio)}</span>
|
||||
<span>${fmtRelativeTime(p.completedAt)}</span>
|
||||
</span>
|
||||
@@ -106,19 +107,19 @@ export function PlansPanel() {
|
||||
${
|
||||
open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
Pick a plan on the left.
|
||||
${t("plans.pickHint")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.summary ?? "(no title)"}</span>
|
||||
<span class="name">${open.summary ?? t("plans.noTitle")}</span>
|
||||
<span class="ws">${open.session} · ${fmtRelativeTime(open.completedAt)}</span>
|
||||
<span class="actions">
|
||||
<button class="btn ghost" onClick=${() => setOpenIdx(null)}>← back</button>
|
||||
<button class="btn ghost" onClick=${() => setOpenIdx(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0 0 6px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">
|
||||
Step timeline · ${open.completedSteps} / ${open.totalSteps}
|
||||
${t("plans.stepTimeline", { done: open.completedSteps, total: open.totalSteps })}
|
||||
</h3>
|
||||
<div class="plan-timeline" style="margin-bottom:14px">
|
||||
${open.steps.map((step, i) => {
|
||||
@@ -126,7 +127,7 @@ export function PlansPanel() {
|
||||
const cls = done ? "done" : i === open.completedSteps ? "active" : "";
|
||||
return html`
|
||||
<div class=${`plan-step ${cls}`}>
|
||||
<span class="lbl">step ${i + 1}</span>
|
||||
<span class="lbl">${t("plans.step", { n: i + 1 })}</span>
|
||||
<span class="name">${step.title}</span>
|
||||
${step.action ? html`<span class="meta">${step.action}</span>` : null}
|
||||
${
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtBytes, fmtNum, fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SemanticData {
|
||||
attached?: boolean;
|
||||
@@ -50,6 +51,7 @@ interface SemanticJob {
|
||||
}
|
||||
|
||||
export function SemanticPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<SemanticData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -81,7 +83,7 @@ export function SemanticPanel() {
|
||||
setInfo(null);
|
||||
try {
|
||||
await api("/semantic/start", { method: "POST", body: { rebuild: !!rebuild } });
|
||||
setInfo(rebuild ? "rebuild started" : "incremental index started");
|
||||
setInfo(rebuild ? t("semantic.rebuildStarted") : t("semantic.incrementalStarted"));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -97,7 +99,7 @@ export function SemanticPanel() {
|
||||
setError(null);
|
||||
try {
|
||||
await api("/semantic/stop", { method: "POST", body: {} });
|
||||
setInfo("stopping requested — current chunk batch will finish first");
|
||||
setInfo(t("semantic.stopRequested"));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -109,11 +111,11 @@ export function SemanticPanel() {
|
||||
const startDaemon = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setInfo("starting ollama daemon (15s timeout)…");
|
||||
setInfo(t("semantic.startingDaemon"));
|
||||
try {
|
||||
const r = await api<{ ready: boolean }>("/semantic/ollama/start", { method: "POST", body: {} });
|
||||
setInfo(
|
||||
r.ready ? "daemon is up" : "daemon didn't come up in time — check `ollama serve` manually",
|
||||
r.ready ? t("semantic.daemonUp") : t("semantic.daemonTimeout"),
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -127,7 +129,7 @@ export function SemanticPanel() {
|
||||
async (model: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setInfo(`pulling ${model} — this may take a few minutes on first install`);
|
||||
setInfo(t("semantic.pullingModel", { model }));
|
||||
try {
|
||||
await api("/semantic/ollama/pull", { method: "POST", body: { model } });
|
||||
await load();
|
||||
@@ -141,14 +143,14 @@ export function SemanticPanel() {
|
||||
);
|
||||
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading semantic status…</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("common.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
|
||||
if (!data.attached) {
|
||||
return html`
|
||||
<div class="card" style="color:var(--fg-3)">
|
||||
<div class="card-h"><span class="title">Semantic — code-mode required</span></div>
|
||||
<div class="card-h"><span class="title">${t("semantic.codeRequired")}</span></div>
|
||||
<div class="card-b">${data.reason}</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -178,12 +180,12 @@ export function SemanticPanel() {
|
||||
<div style="display:flex;flex-direction:column;gap:10px;min-width:0">
|
||||
<div class="chips">
|
||||
<span class=${`chip-f ${idx?.exists ? "active" : ""}`}>
|
||||
${idx?.exists ? "index built" : "no index yet"}
|
||||
${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)">ready</span>`
|
||||
: html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">setup needed</span>`
|
||||
? 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>`
|
||||
}
|
||||
</div>
|
||||
${info ? html`<div><span class="pill info">${info}</span></div>` : null}
|
||||
@@ -195,14 +197,14 @@ export function SemanticPanel() {
|
||||
!binaryFound
|
||||
? html`
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">Install Ollama</span></div>
|
||||
<div class="card-h"><span class="title">${t("semantic.installOllama")}</span></div>
|
||||
<div class="card-b" style="font-size:13px">
|
||||
Reasonix doesn't run package managers for you. Install Ollama first, then come back:
|
||||
${t("semantic.installOllamaDesc")}
|
||||
<ul style="margin:10px 0 4px 18px;padding:0">
|
||||
<li><strong>macOS / Windows:</strong> download from <a href="https://ollama.com/download" target="_blank" rel="noreferrer">ollama.com/download</a></li>
|
||||
<li><strong>Linux:</strong> <code class="mono">curl -fsSL https://ollama.com/install.sh | sh</code></li>
|
||||
<li><strong>${t("semantic.macWindows")}</strong> ${t("semantic.download")} <a href="https://ollama.com/download" target="_blank" rel="noreferrer">ollama.com/download</a></li>
|
||||
<li><strong>${t("semantic.linux")}</strong> <code class="mono">curl -fsSL https://ollama.com/install.sh | sh</code></li>
|
||||
</ul>
|
||||
<div style="color:var(--fg-3);margin-top:8px">Refresh after install — this panel will offer to start the daemon and pull <code class="mono">${modelName}</code>.</div>
|
||||
<div style="color:var(--fg-3);margin-top:8px">${t("semantic.refreshHint", { model: modelName })}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -212,12 +214,12 @@ export function SemanticPanel() {
|
||||
binaryFound && !daemonRunning
|
||||
? html`
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">Daemon</span></div>
|
||||
<div class="card-h"><span class="title">${t("semantic.daemon")}</span></div>
|
||||
<div class="card-b" style="font-size:13px">
|
||||
<code class="mono">ollama</code> is on your PATH but the HTTP daemon isn't reachable.
|
||||
${t("semantic.daemonDesc")}
|
||||
<div style="display:flex;gap:8px;margin-top:10px;align-items:center">
|
||||
<button class="primary" disabled=${busy} onClick=${startDaemon}>Start daemon</button>
|
||||
<span style="color:var(--fg-3);font-size:12px">runs <code class="mono">ollama serve</code> detached</span>
|
||||
<button class="primary" disabled=${busy} onClick=${startDaemon}>${t("semantic.startDaemon")}</button>
|
||||
<span style="color:var(--fg-3);font-size:12px">${t("semantic.runsOllama")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,12 +230,12 @@ export function SemanticPanel() {
|
||||
daemonRunning && !modelPulled
|
||||
? html`
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">Model</span></div>
|
||||
<div class="card-h"><span class="title">${t("semantic.model")}</span></div>
|
||||
<div class="card-b" style="font-size:13px">
|
||||
<code class="mono">${modelName}</code> isn't installed yet.${pulling ? "" : " ~270 MB on first pull."}
|
||||
${t("semantic.modelMissing", { model: modelName })}${pulling ? "" : ` ${t("semantic.modelSize")}`}
|
||||
<div style="display:flex;gap:8px;margin-top:10px">
|
||||
<button class="primary" disabled=${busy || pulling} onClick=${() => pullModel(modelName)}>
|
||||
${pulling ? "pulling…" : `Pull ${modelName}`}
|
||||
${pulling ? t("semantic.pulling") : t("semantic.pullModel", { model: modelName })}
|
||||
</button>
|
||||
</div>
|
||||
${
|
||||
@@ -256,7 +258,7 @@ export function SemanticPanel() {
|
||||
${
|
||||
job
|
||||
? html`
|
||||
${sectionH3("Job")}
|
||||
${sectionH3(t("semantic.job"))}
|
||||
<${SemanticJobView} job=${job} running=${running} />
|
||||
`
|
||||
: null
|
||||
@@ -266,55 +268,55 @@ export function SemanticPanel() {
|
||||
<aside style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="title">index status</span>
|
||||
<span class="title">${t("semantic.indexStatus")}</span>
|
||||
<span class="meta">
|
||||
${
|
||||
idx?.exists
|
||||
? html`<span class="pill ok">● built</span>`
|
||||
: html`<span class="pill">none</span>`
|
||||
? html`<span class="pill ok">${t("semantic.builtStatus")}</span>`
|
||||
: html`<span class="pill">${t("system.none")}</span>`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
${
|
||||
idx?.exists
|
||||
? html`
|
||||
<div class="rail-kv"><span class="k">chunks</span><span class="v">${fmtNum(idx.chunks)}</span></div>
|
||||
<div class="rail-kv"><span class="k">files</span><span class="v">${fmtNum(idx.files)}</span></div>
|
||||
<div class="rail-kv"><span class="k">model</span><span class="v" style="font-size:11px">${idx.model ?? modelName}</span></div>
|
||||
<div class="rail-kv"><span class="k">dim</span><span class="v">${fmtNum(idx.dim)}</span></div>
|
||||
<div class="rail-kv"><span class="k">size</span><span class="v">${fmtBytes(idx.sizeBytes)}</span></div>
|
||||
<div class="rail-kv"><span class="k">last build</span><span class="v">${fmtRelativeTime(idx.lastBuiltMs ?? null)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.chunks")}</span><span class="v">${fmtNum(idx.chunks)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.files")}</span><span class="v">${fmtNum(idx.files)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v" style="font-size:11px">${idx.model ?? modelName}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.dim")}</span><span class="v">${fmtNum(idx.dim)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.size")}</span><span class="v">${fmtBytes(idx.sizeBytes)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.lastBuild")}</span><span class="v">${fmtRelativeTime(idx.lastBuiltMs ?? null)}</span></div>
|
||||
`
|
||||
: html`
|
||||
<div style="color:var(--fg-3);font-size:12.5px;padding:6px 0">
|
||||
Run an index to enable <code class="mono">semantic_search</code>.
|
||||
${t("semantic.runIndexHint")}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap">
|
||||
<button class="primary" disabled=${busy || running || !ready} onClick=${() => start(false)}>${idx?.exists ? "Re-index" : "Build"}</button>
|
||||
<button class="primary" disabled=${busy || running || !ready} onClick=${() => start(false)}>${idx?.exists ? t("semantic.reIndex") : t("semantic.build")}</button>
|
||||
${
|
||||
idx?.exists
|
||||
? html`<button disabled=${busy || running || !ready} onClick=${() => start(true)}>Rebuild</button>`
|
||||
? html`<button disabled=${busy || running || !ready} onClick=${() => start(true)}>${t("semantic.rebuild")}</button>`
|
||||
: null
|
||||
}
|
||||
${running ? html`<button onClick=${stop} style="border-color:var(--c-err);color:var(--c-err)">Stop</button>` : null}
|
||||
${running ? html`<button onClick=${stop} style="border-color:var(--c-err);color:var(--c-err)">${t("semantic.stop")}</button>` : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">ollama</span></div>
|
||||
<div class="card-h"><span class="title">${t("semantic.ollama")}</span></div>
|
||||
<div class="rail-kv">
|
||||
<span class="k">binary</span>
|
||||
<span class="v">${binaryFound ? html`<span class="pill ok">found</span>` : html`<span class="pill err">missing</span>`}</span>
|
||||
<span class="k">${t("semantic.binary")}</span>
|
||||
<span class="v">${binaryFound ? html`<span class="pill ok">${t("semantic.found")}</span>` : html`<span class="pill err">${t("semantic.missing")}</span>`}</span>
|
||||
</div>
|
||||
<div class="rail-kv">
|
||||
<span class="k">daemon</span>
|
||||
<span class="v">${daemonRunning ? html`<span class="pill ok">up</span>` : html`<span class="pill warn">down</span>`}</span>
|
||||
<span class="k">${t("semantic.daemonStatus")}</span>
|
||||
<span class="v">${daemonRunning ? html`<span class="pill ok">${t("semantic.up")}</span>` : html`<span class="pill warn">${t("semantic.down")}</span>`}</span>
|
||||
</div>
|
||||
<div class="rail-kv">
|
||||
<span class="k">model</span>
|
||||
<span class="v">${modelPulled ? html`<span class="pill ok">pulled</span>` : html`<span class="pill warn">missing</span>`}</span>
|
||||
<span class="k">${t("semantic.model")}</span>
|
||||
<span class="v">${modelPulled ? html`<span class="pill ok">${t("semantic.pulled")}</span>` : html`<span class="pill warn">${t("semantic.missing")}</span>`}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -369,6 +371,7 @@ interface SearchResponse {
|
||||
}
|
||||
|
||||
function SemanticSearchSection() {
|
||||
useLang();
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
const [meta, setMeta] = useState<{ elapsedMs: number; model: string } | null>(null);
|
||||
@@ -403,7 +406,7 @@ function SemanticSearchSection() {
|
||||
type="text"
|
||||
class="mono"
|
||||
style="width:100%;padding:10px 14px 10px 38px;font-size:13.5px;background:var(--bg-input);border:1px solid var(--bd);border-radius:var(--r);color:var(--fg-0);outline:none"
|
||||
placeholder="describe what to find — 'where do we handle abort signals'"
|
||||
placeholder=${t("semantic.searchPlaceholder")}
|
||||
value=${query}
|
||||
disabled=${busy}
|
||||
onInput=${(e: Event) => setQuery((e.target as HTMLInputElement).value)}
|
||||
@@ -420,11 +423,11 @@ function SemanticSearchSection() {
|
||||
? html`
|
||||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--fg-3);margin:8px 0 6px;display:flex;align-items:center;gap:8px">
|
||||
${busy
|
||||
? html`<span>searching…</span>`
|
||||
? html`<span>${t("semantic.searching")}</span>`
|
||||
: error
|
||||
? html`<span style="color:var(--c-err)">${error}</span>`
|
||||
: hits
|
||||
? html`<span>${hits.length} result${hits.length === 1 ? "" : "s"} · ${meta?.elapsedMs ?? 0}ms · ${meta?.model ?? ""}</span>`
|
||||
? html`<span>${t("semantic.results", { count: hits.length, s: hits.length === 1 ? "" : "s", ms: meta?.elapsedMs ?? 0, model: meta?.model ?? "" })}</span>`
|
||||
: null}
|
||||
</div>
|
||||
${
|
||||
@@ -446,7 +449,7 @@ function SemanticSearchSection() {
|
||||
</div>
|
||||
`
|
||||
: hits && hits.length === 0 && !busy
|
||||
? html`<div class="card" style="color:var(--fg-3);font-size:12px">No matches above the score threshold.</div>`
|
||||
? html`<div class="card" style="color:var(--fg-3);font-size:12px">${t("semantic.noMatches")}</div>`
|
||||
: null
|
||||
}
|
||||
`
|
||||
@@ -485,6 +488,7 @@ function fromDraft(d: ExcludeDraft): IndexConfig {
|
||||
}
|
||||
|
||||
function SemanticExcludesCard() {
|
||||
useLang();
|
||||
const [data, setData] = useState<IndexConfigResponse | null>(null);
|
||||
const [draft, setDraft] = useState<ExcludeDraft | null>(null);
|
||||
const [preview, setPreview] = useState<PreviewData | null>(null);
|
||||
@@ -522,7 +526,7 @@ function SemanticExcludesCard() {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
setInfo(`saved · ${r.changed.length || 0} fields updated · re-run index to apply`);
|
||||
setInfo(t("semantic.savedConfig", { count: r.changed.length || 0 }));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -535,7 +539,7 @@ function SemanticExcludesCard() {
|
||||
if (!draft) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setInfo("running dry walk against project root…");
|
||||
setInfo(t("semantic.runningPreview"));
|
||||
try {
|
||||
const payload = fromDraft(draft);
|
||||
const r = await api<PreviewData>("/index-config/preview", {
|
||||
@@ -555,8 +559,8 @@ function SemanticExcludesCard() {
|
||||
if (!draft) {
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">index config</span></div>
|
||||
<div style="color:var(--fg-3);font-size:12.5px">loading…</div>
|
||||
<div class="card-h"><span class="title">${t("semantic.indexConfig")}</span></div>
|
||||
<div style="color:var(--fg-3);font-size:12.5px">${t("common.loading")}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -564,39 +568,39 @@ function SemanticExcludesCard() {
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="title">index config</span>
|
||||
<span class="title">${t("semantic.indexConfig")}</span>
|
||||
<span class="meta">
|
||||
<a
|
||||
class="mono"
|
||||
style="color:var(--c-brand);text-decoration:none;font-size:11px;cursor:pointer"
|
||||
onClick=${reset}
|
||||
>reset</a>
|
||||
>${t("semantic.reset")}</a>
|
||||
</span>
|
||||
</div>
|
||||
${info ? html`<div style="margin-bottom:8px"><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err" style="margin-bottom:8px">${error}</div>` : null}
|
||||
|
||||
<${ChipFormRow}
|
||||
label="exclude dirs"
|
||||
label=${t("semantic.excludeDirs")}
|
||||
value=${draft.excludeDirs}
|
||||
onChange=${(v: string[]) => setDraft({ ...draft, excludeDirs: v })}
|
||||
placeholder="dist"
|
||||
/>
|
||||
<${ChipFormRow}
|
||||
label="exclude files"
|
||||
label=${t("semantic.excludeFiles")}
|
||||
value=${draft.excludeFiles}
|
||||
onChange=${(v: string[]) => setDraft({ ...draft, excludeFiles: v })}
|
||||
placeholder="package-lock.json"
|
||||
/>
|
||||
<${ChipFormRow}
|
||||
label="exclude exts"
|
||||
label=${t("semantic.excludeExts")}
|
||||
value=${draft.excludeExts}
|
||||
onChange=${(v: string[]) => setDraft({ ...draft, excludeExts: v })}
|
||||
placeholder=".lock"
|
||||
/>
|
||||
<${ChipFormRow}
|
||||
label="exclude patterns"
|
||||
sub="glob"
|
||||
label=${t("semantic.excludePatterns")}
|
||||
sub=${t("semantic.glob")}
|
||||
value=${draft.excludePatterns}
|
||||
onChange=${(v: string[]) => setDraft({ ...draft, excludePatterns: v })}
|
||||
placeholder="**/*.test.ts"
|
||||
@@ -608,11 +612,11 @@ function SemanticExcludesCard() {
|
||||
onClick=${() => setDraft({ ...draft, respectGitignore: !draft.respectGitignore })}
|
||||
>
|
||||
<span class=${`box ${draft.respectGitignore ? "on" : ""}`}>${draft.respectGitignore ? "✓" : ""}</span>
|
||||
<span>respect <code class="mono">.gitignore</code></span>
|
||||
<span>${t("semantic.respectGitignore")}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-top:10px">
|
||||
<span class="lbl">max file bytes</span>
|
||||
<span class="lbl">${t("semantic.maxFileBytes")}</span>
|
||||
<input
|
||||
class="input mono"
|
||||
type="number"
|
||||
@@ -623,14 +627,14 @@ function SemanticExcludesCard() {
|
||||
setDraft({ ...draft, maxFileBytes: Number((e.target as HTMLInputElement).value) || 0 })}
|
||||
style="font-size:12px"
|
||||
/>
|
||||
<span class="help">skip files larger than ~${(draft.maxFileBytes / 1024 / 1024).toFixed(1)} MiB</span>
|
||||
<span class="help">${t("semantic.skipLarger", { size: (draft.maxFileBytes / 1024 / 1024).toFixed(1) })}</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:6px;margin-top:10px">
|
||||
<button class="btn ghost" style="flex:1" disabled=${busy} onClick=${runPreview}>
|
||||
<span class="g">⊕</span><span>Preview</span>
|
||||
<span class="g">⊕</span><span>${t("semantic.preview")}</span>
|
||||
</button>
|
||||
<button class="btn primary" style="flex:1" disabled=${busy} onClick=${save}>Save</button>
|
||||
<button class="btn primary" style="flex:1" disabled=${busy} onClick=${save}>${t("common.save")}</button>
|
||||
</div>
|
||||
|
||||
${preview ? html`<div style="margin-top:10px"><${ExcludesPreview} preview=${preview} /></div>` : null}
|
||||
@@ -639,6 +643,7 @@ function SemanticExcludesCard() {
|
||||
}
|
||||
|
||||
function ExcludesPreview({ preview }: { preview: PreviewData }) {
|
||||
useLang();
|
||||
const buckets = preview.skipBuckets || {};
|
||||
const samples = preview.skipSamples || {};
|
||||
const totalSkipped = Object.values(buckets).reduce((a, b) => a + (b || 0), 0);
|
||||
@@ -655,11 +660,11 @@ function ExcludesPreview({ preview }: { preview: PreviewData }) {
|
||||
return html`
|
||||
<div class="excludes-preview">
|
||||
<div class="summary">
|
||||
Preview — would index <strong>${preview.filesIncluded}</strong> file(s), skip <strong>${totalSkipped}</strong>
|
||||
${t("semantic.previewSummary", { included: preview.filesIncluded, skipped: totalSkipped })}
|
||||
</div>
|
||||
${
|
||||
reasons.length === 0
|
||||
? html`<div style="color:var(--fg-3)">nothing skipped — all walked files would be indexed.</div>`
|
||||
? html`<div style="color:var(--fg-3)">${t("semantic.nothingSkipped")}</div>`
|
||||
: reasons.map(
|
||||
(r) => html`
|
||||
<details>
|
||||
@@ -680,7 +685,7 @@ function ExcludesPreview({ preview }: { preview: PreviewData }) {
|
||||
preview.sampleIncluded?.length
|
||||
? html`
|
||||
<details>
|
||||
<summary>first ${preview.sampleIncluded.length} included file(s)</summary>
|
||||
<summary>${t("semantic.firstIncluded", { count: preview.sampleIncluded.length })}</summary>
|
||||
<ul>
|
||||
${preview.sampleIncluded.map((p) => html`<li><code>${p}</code></li>`)}
|
||||
</ul>
|
||||
@@ -751,13 +756,14 @@ function ChipFormRow({
|
||||
}
|
||||
|
||||
function SemanticJobView({ job, running }: { job: SemanticJob; running: boolean }) {
|
||||
useLang();
|
||||
const phaseLabel =
|
||||
({
|
||||
scan: "scanning files",
|
||||
embed: "embedding chunks",
|
||||
write: "writing index",
|
||||
done: "done",
|
||||
error: "error",
|
||||
scan: t("semantic.phaseScan"),
|
||||
embed: t("semantic.phaseEmbed"),
|
||||
write: t("semantic.phaseWrite"),
|
||||
done: t("semantic.phaseDone"),
|
||||
error: t("semantic.phaseError"),
|
||||
} as Record<string, string>)[job.phase] ?? job.phase;
|
||||
const total = job.chunksTotal ?? 0;
|
||||
const doneN = job.chunksDone ?? 0;
|
||||
@@ -768,21 +774,21 @@ function SemanticJobView({ job, running }: { job: SemanticJob; running: boolean
|
||||
<div class="kv">
|
||||
<div><span class="kv-key">phase</span>
|
||||
<span class=${`pill ${job.phase === "error" ? "pill-err" : running ? "pill-active" : "pill-dim"}`}>${phaseLabel}</span>
|
||||
${job.aborted ? html`<span class="pill warn" style="margin-left: 6px;">stopping</span>` : null}
|
||||
${job.aborted ? html`<span class="pill warn" style="margin-left: 6px;">${t("semantic.stopping")}</span>` : null}
|
||||
<span style="color:var(--fg-3);margin-left:8px">${elapsed}s</span>
|
||||
</div>
|
||||
${
|
||||
job.filesScanned !== null && job.filesScanned !== undefined
|
||||
? html`<div><span class="kv-key">files</span>scanned ${job.filesScanned}${
|
||||
job.filesChanged != null ? ` · changed ${job.filesChanged}` : ""
|
||||
}${job.filesSkipped ? ` · skipped ${job.filesSkipped}` : ""}</div>`
|
||||
? html`<div><span class="kv-key">${t("semantic.files")}</span>${t("semantic.scanned", { count: job.filesScanned })}${
|
||||
job.filesChanged != null ? ` · ${t("semantic.changed", { count: job.filesChanged })}` : ""
|
||||
}${job.filesSkipped ? ` · ${t("semantic.skipped", { count: job.filesSkipped })}` : ""}</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
total > 0
|
||||
? html`
|
||||
<div>
|
||||
<span class="kv-key">chunks</span>${doneN} / ${total} (${(ratio * 100).toFixed(0)}%)
|
||||
<span class="kv-key">${t("semantic.chunks")}</span>${t("semantic.chunksProgress", { done: doneN, total, pct: (ratio * 100).toFixed(0) })}
|
||||
</div>
|
||||
<div class="bar" style="margin-top: 4px;">
|
||||
<div class="fill" style=${`width: ${(ratio * 100).toFixed(1)}%; background: var(--primary);`}></div>
|
||||
@@ -792,13 +798,13 @@ function SemanticJobView({ job, running }: { job: SemanticJob; running: boolean
|
||||
}
|
||||
${
|
||||
job.error
|
||||
? html`<div><span class="kv-key">error</span><span class="err">${job.error}</span></div>`
|
||||
? html`<div><span class="kv-key">${t("semantic.phaseError")}</span><span class="err">${job.error}</span></div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
job.result
|
||||
? html`<div><span class="kv-key">result</span>added ${job.result.chunksAdded} · removed ${job.result.chunksRemoved}${
|
||||
job.result.chunksSkipped ? ` · failed ${job.result.chunksSkipped}` : ""
|
||||
? html`<div><span class="kv-key">${t("semantic.result")}</span>${t("semantic.added", { count: job.result.chunksAdded })} · ${t("semantic.removed", { count: job.result.chunksRemoved })}${
|
||||
job.result.chunksSkipped ? ` · ${t("semantic.failed", { count: job.result.chunksSkipped })}` : ""
|
||||
} · ${(job.result.durationMs / 1000).toFixed(1)}s</div>`
|
||||
: null
|
||||
}
|
||||
@@ -812,6 +818,7 @@ function SemanticJobView({ job, running }: { job: SemanticJob; running: boolean
|
||||
}
|
||||
|
||||
function SkipBucketsView({ buckets }: { buckets: Record<string, number> }) {
|
||||
useLang();
|
||||
const order: [string, string][] = [
|
||||
["gitignore", "gitignore"],
|
||||
["pattern", "pattern"],
|
||||
@@ -827,5 +834,5 @@ function SkipBucketsView({ buckets }: { buckets: Record<string, number> }) {
|
||||
const parts = order
|
||||
.filter(([k]) => (buckets[k] || 0) > 0)
|
||||
.map(([k, label]) => `${label}: ${buckets[k]}`);
|
||||
return html`<div><span class="kv-key">skipped</span>${total} files <span style="color:var(--fg-3)">(${parts.join(", ")})</span></div>`;
|
||||
return html`<div><span class="kv-key">${t("semantic.skipped")}</span>${t("semantic.skippedFiles", { total, details: parts.join(", ") })}</div>`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { api } from "../lib/api.js";
|
||||
import { fmtBytes, fmtNum, fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SessionEntry {
|
||||
name: string;
|
||||
@@ -23,6 +24,7 @@ interface OpenSession {
|
||||
}
|
||||
|
||||
export function SessionsPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<SessionsData>("/sessions", 5000);
|
||||
const [open, setOpen] = useState<OpenSession | null>(null);
|
||||
const [openLoading, setOpenLoading] = useState(false);
|
||||
@@ -42,12 +44,12 @@ export function SessionsPanel() {
|
||||
}, []);
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading sessions…</div>`;
|
||||
if (error) return html`<div class="card accent-err">sessions failed: ${error.message}</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("sessions.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "sessions", error: error.message })}</div>`;
|
||||
const sessions = data?.sessions ?? [];
|
||||
|
||||
if (sessions.length === 0)
|
||||
return html`<div class="card" style="color:var(--fg-3)">No saved sessions yet.</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("sessions.noSessions")}</div>`;
|
||||
|
||||
const filtered = filter.trim()
|
||||
? sessions.filter((s) => s.name.toLowerCase().includes(filter.toLowerCase()))
|
||||
@@ -59,14 +61,14 @@ export function SessionsPanel() {
|
||||
<div class="ssl-h">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="filter sessions"
|
||||
placeholder=${t("sessions.filterPlaceholder")}
|
||||
value=${filter}
|
||||
onInput=${(e: Event) => setFilter((e.target as HTMLInputElement).value)}
|
||||
style="flex:1"
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">all <span class="ct">${sessions.length}</span></span>
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
|
||||
</div>
|
||||
<div class="ssl-rows">
|
||||
${filtered.map(
|
||||
@@ -77,7 +79,7 @@ export function SessionsPanel() {
|
||||
>
|
||||
<span class="name">${s.name}</span>
|
||||
<span class="meta">
|
||||
<span><span class="v">${fmtNum(s.messageCount)}</span> msgs</span>
|
||||
<span><span class="v">${fmtNum(s.messageCount)}</span> ${t("sessions.msgs")}</span>
|
||||
<span><span class="v">${fmtBytes(s.size)}</span></span>
|
||||
<span>${fmtRelativeTime(s.mtime)}</span>
|
||||
</span>
|
||||
@@ -91,7 +93,7 @@ export function SessionsPanel() {
|
||||
${
|
||||
open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
Pick a session on the left to read its transcript.
|
||||
${t("sessions.pickHint")}
|
||||
</div>`
|
||||
: html`
|
||||
<div class="sessions-detail-h">
|
||||
@@ -99,24 +101,24 @@ export function SessionsPanel() {
|
||||
<span class="ws">
|
||||
${
|
||||
open.messages
|
||||
? `${open.messages.length} message${open.messages.length === 1 ? "" : "s"}`
|
||||
: "loading…"
|
||||
? t("sessions.messages", { count: open.messages.length, s: open.messages.length === 1 ? "" : "s" })
|
||||
: t("common.loading")
|
||||
}
|
||||
</span>
|
||||
<span class="actions">
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>← back</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card accent-brand" style="margin-bottom:10px">
|
||||
<div class="card-h"><span class="title">Resume in TUI</span></div>
|
||||
<div class="card-h"><span class="title">${t("sessions.resumeTitle")}</span></div>
|
||||
<div class="card-b" style="font-size:12.5px;color:var(--fg-2)">
|
||||
Mid-session swap requires a restart so the message log can rewind cleanly. Quit your current session, then run:
|
||||
${t("sessions.resumeDesc")}
|
||||
<code class="mono" style="display:block;margin-top:8px;padding:8px 10px;background:var(--bg-input);border-radius:var(--r);color:var(--fg-0);font-size:12px;user-select:all">reasonix chat --session ${open.name}</code>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
openLoading
|
||||
? html`<div style="color:var(--fg-3)">loading transcript…</div>`
|
||||
? html`<div style="color:var(--fg-3)">${t("sessions.loadingTranscript")}</div>`
|
||||
: open.error
|
||||
? html`<div class="card accent-err">${open.error}</div>`
|
||||
: open.messages && open.messages.length > 0
|
||||
@@ -143,7 +145,7 @@ export function SessionsPanel() {
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: html`<div style="color:var(--fg-3)">empty transcript.</div>`
|
||||
: html`<div style="color:var(--fg-3)">${t("sessions.emptyTranscript")}</div>`
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { type DashboardLang, getLang, setLang, t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SettingsData {
|
||||
apiKey?: string | null;
|
||||
@@ -13,6 +14,7 @@ interface SettingsData {
|
||||
}
|
||||
|
||||
export function SettingsPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<SettingsData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -39,7 +41,7 @@ export function SettingsPanel() {
|
||||
try {
|
||||
await api("/settings", { method: "POST", body: fields });
|
||||
await load();
|
||||
setSaved(`saved: ${Object.keys(fields).join(", ")}`);
|
||||
setSaved(t("settings.saved", { fields: Object.keys(fields).join(", ") }));
|
||||
setTimeout(() => setSaved(null), 3000);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -51,7 +53,7 @@ export function SettingsPanel() {
|
||||
);
|
||||
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading settings…</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("settings.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
const v = data;
|
||||
@@ -71,6 +73,8 @@ export function SettingsPanel() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const currentLang = getLang();
|
||||
|
||||
return html`
|
||||
<div style="max-width:760px;display:flex;flex-direction:column;gap:6px">
|
||||
${
|
||||
@@ -80,18 +84,37 @@ export function SettingsPanel() {
|
||||
error ? html`<div class="card accent-err">${error}</div>` : null
|
||||
}
|
||||
|
||||
${sectionH3("DeepSeek API")}
|
||||
${sectionH3(t("settings.sectionLanguage"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
"API key",
|
||||
html`<code class="mono" style="color:var(--fg-2);font-size:11.5px">${v.apiKey ?? "(not set)"}</code>`,
|
||||
t("settings.language"),
|
||||
html`
|
||||
<select
|
||||
value=${currentLang}
|
||||
onChange=${(e: Event) => {
|
||||
const lang = (e.target as HTMLSelectElement).value as DashboardLang;
|
||||
setLang(lang);
|
||||
}}
|
||||
>
|
||||
<option value="en">${t("settings.langEn")}</option>
|
||||
<option value="zh-CN">${t("settings.langZhCn")}</option>
|
||||
</select>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionApi"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.apiKey"),
|
||||
html`<code class="mono" style="color:var(--fg-2);font-size:11.5px">${v.apiKey ?? t("settings.notSet")}</code>`,
|
||||
)}
|
||||
${fieldRow(
|
||||
"replace",
|
||||
t("settings.replace"),
|
||||
html`
|
||||
<input
|
||||
type="password"
|
||||
placeholder="paste a fresh sk-… token"
|
||||
placeholder=${t("settings.pasteKey")}
|
||||
value=${draft.apiKey ?? ""}
|
||||
onInput=${(e: Event) => setDraft({ ...draft, apiKey: (e.target as HTMLInputElement).value })}
|
||||
style="flex:1"
|
||||
@@ -100,16 +123,16 @@ export function SettingsPanel() {
|
||||
class="btn primary"
|
||||
disabled=${saving || !(draft.apiKey ?? "").trim()}
|
||||
onClick=${() => save({ apiKey: draft.apiKey })}
|
||||
>Save key</button>
|
||||
>${t("settings.saveKey")}</button>
|
||||
`,
|
||||
)}
|
||||
${fieldRow(
|
||||
"base url",
|
||||
t("settings.baseUrl"),
|
||||
html`
|
||||
<input
|
||||
type="text"
|
||||
value=${draft.baseUrl ?? v.baseUrl ?? ""}
|
||||
placeholder="https://api.deepseek.com (default)"
|
||||
placeholder=${t("settings.baseUrlPlaceholder")}
|
||||
onInput=${(e: Event) => setDraft({ ...draft, baseUrl: (e.target as HTMLInputElement).value })}
|
||||
style="flex:1"
|
||||
/>
|
||||
@@ -117,65 +140,65 @@ export function SettingsPanel() {
|
||||
class="btn"
|
||||
disabled=${saving || (draft.baseUrl ?? v.baseUrl ?? "") === (v.baseUrl ?? "")}
|
||||
onClick=${() => save({ baseUrl: draft.baseUrl })}
|
||||
>Save</button>
|
||||
>${t("common.save")}</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3("Defaults")}
|
||||
${sectionH3(t("settings.sectionDefaults"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
"preset",
|
||||
t("settings.preset"),
|
||||
html`
|
||||
<select
|
||||
value=${["auto", "flash", "pro"].includes(v.preset ?? "") ? v.preset : "auto"}
|
||||
onChange=${(e: Event) => save({ preset: (e.target as HTMLSelectElement).value })}
|
||||
disabled=${saving}
|
||||
>
|
||||
<option value="auto">auto — flash → pro on hard turns</option>
|
||||
<option value="flash">flash — always flash, no auto-escalate</option>
|
||||
<option value="pro">pro — always pro</option>
|
||||
<option value="auto">${t("settings.presetAuto")}</option>
|
||||
<option value="flash">${t("settings.presetFlash")}</option>
|
||||
<option value="pro">${t("settings.presetPro")}</option>
|
||||
</select>
|
||||
`,
|
||||
"applies next turn",
|
||||
t("settings.appliesNextTurn"),
|
||||
)}
|
||||
${fieldRow(
|
||||
"effort",
|
||||
t("settings.effort"),
|
||||
html`
|
||||
<select
|
||||
value=${v.reasoningEffort}
|
||||
onChange=${(e: Event) => save({ reasoningEffort: (e.target as HTMLSelectElement).value })}
|
||||
disabled=${saving}
|
||||
>
|
||||
<option value="max">max (default — best)</option>
|
||||
<option value="high">high (cheaper / faster)</option>
|
||||
<option value="max">${t("settings.effortMax")}</option>
|
||||
<option value="high">${t("settings.effortHigh")}</option>
|
||||
</select>
|
||||
`,
|
||||
"applies next turn",
|
||||
t("settings.appliesNextTurn"),
|
||||
)}
|
||||
${fieldRow(
|
||||
"web search",
|
||||
t("settings.webSearch"),
|
||||
html`
|
||||
<button
|
||||
class=${`btn ${v.search ? "primary" : ""}`}
|
||||
onClick=${() => save({ search: !v.search })}
|
||||
disabled=${saving}
|
||||
>${v.search ? "ON" : "off"}</button>
|
||||
>${v.search ? t("common.on") : t("common.off")}</button>
|
||||
`,
|
||||
"web_fetch + web_search tools",
|
||||
t("settings.webSearchNote"),
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3("Runtime")}
|
||||
${sectionH3(t("settings.sectionRuntime"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
"active model",
|
||||
t("settings.activeModel"),
|
||||
html`<code class="mono">${v.model ?? "—"}</code>`,
|
||||
)}
|
||||
${fieldRow(
|
||||
"edit mode",
|
||||
t("settings.editMode"),
|
||||
html`<code class="mono">${v.editMode}</code>`,
|
||||
"switch from the Chat tab header",
|
||||
t("settings.editModeNote"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SkillEntry {
|
||||
name: string;
|
||||
@@ -18,6 +19,7 @@ interface SkillsData {
|
||||
type Scope = "project" | "global" | "builtin";
|
||||
|
||||
export function SkillsPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<SkillsData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<{ scope: Scope; name: string } | null>(null);
|
||||
@@ -64,7 +66,7 @@ export function SkillsPanel() {
|
||||
method: "POST",
|
||||
body: { body },
|
||||
});
|
||||
setInfo(`saved ${open.scope}/${open.name}`);
|
||||
setInfo(t("skills.saved", { scope: open.scope, name: open.name }));
|
||||
setTimeout(() => setInfo(null), 3000);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -76,7 +78,7 @@ export function SkillsPanel() {
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
if (!open) return;
|
||||
if (!confirm(`Delete skill ${open.scope}/${open.name}?`)) return;
|
||||
if (!confirm(t("skills.deleteConfirm", { scope: open.scope, name: open.name }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/skills/${open.scope}/${encodeURIComponent(open.name)}`, { method: "DELETE" });
|
||||
@@ -109,7 +111,7 @@ export function SkillsPanel() {
|
||||
}, [newName, newScope, load, openSkill]);
|
||||
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading skills…</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("skills.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
|
||||
@@ -132,17 +134,17 @@ export function SkillsPanel() {
|
||||
<div class="ssl-h">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="filter skills"
|
||||
placeholder=${t("skills.filterPlaceholder")}
|
||||
value=${filter}
|
||||
onInput=${(e: Event) => setFilter((e.target as HTMLInputElement).value)}
|
||||
style="flex:1"
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">all <span class="ct">${allWith.length}</span></span>
|
||||
<span class="chip-f">project <span class="ct">${data.project.length}</span></span>
|
||||
<span class="chip-f">global <span class="ct">${data.global.length}</span></span>
|
||||
<span class="chip-f">builtin <span class="ct">${data.builtin.length}</span></span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div style="padding:0 12px 8px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
@@ -151,12 +153,12 @@ export function SkillsPanel() {
|
||||
onChange=${(e: Event) => setNewScope((e.target as HTMLSelectElement).value as "global" | "project")}
|
||||
style="flex:0 0 auto;font-size:11.5px;padding:5px 6px"
|
||||
>
|
||||
<option value="global">global</option>
|
||||
${data.paths.project ? html`<option value="project">project</option>` : null}
|
||||
<option value="global">${t("skills.global")}</option>
|
||||
${data.paths.project ? html`<option value="project">${t("skills.project")}</option>` : null}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="new skill"
|
||||
placeholder=${t("skills.newSkill")}
|
||||
value=${newName}
|
||||
onInput=${(e: Event) => setNewName((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;min-width:0"
|
||||
@@ -174,13 +176,13 @@ export function SkillsPanel() {
|
||||
>
|
||||
<span class="name">
|
||||
${s.name}
|
||||
${s.scope === "builtin" ? html`<span class="pill">builtin</span>` : null}
|
||||
${s.scope === "builtin" ? html`<span class="pill">${t("skills.builtin")}</span>` : null}
|
||||
</span>
|
||||
<span class="preview">${s.description ?? "(no description)"}</span>
|
||||
<span class="preview">${s.description ?? t("skills.noDescription")}</span>
|
||||
<span class="meta">
|
||||
${
|
||||
typeof s.runs7d === "number" && s.runs7d > 0
|
||||
? html`<span><span class="v">${s.runs7d}</span> runs · 7d</span>`
|
||||
? html`<span><span class="v">${s.runs7d}</span> ${t("skills.runs7d")}</span>`
|
||||
: null
|
||||
}
|
||||
<span class="dim">${s.scope}</span>
|
||||
@@ -195,7 +197,7 @@ export function SkillsPanel() {
|
||||
${
|
||||
open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
Pick a skill on the left, or create a new one above.
|
||||
${t("skills.pickHint")}
|
||||
</div>`
|
||||
: open.scope === "builtin"
|
||||
? (() => {
|
||||
@@ -203,16 +205,16 @@ export function SkillsPanel() {
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.scope}/${open.name}</span>
|
||||
<span class="ws"><span class="pill">read-only · builtin</span></span>
|
||||
<span class="ws"><span class="pill">${t("skills.readOnlyBuiltin")}</span></span>
|
||||
<span class="actions">
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>← back</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div style="color:var(--fg-2);font-size:13px;line-height:1.6">
|
||||
${builtin?.description ?? "(no description)"}
|
||||
${builtin?.description ?? t("skills.noDescription")}
|
||||
</div>
|
||||
<div style="margin-top:14px;color:var(--fg-3);font-size:11.5px">
|
||||
Built-in skills ship with Reasonix; the model picks them up automatically. To customize, create a project- or global-scoped skill with the same name.
|
||||
${t("skills.builtinDesc")}
|
||||
</div>
|
||||
`;
|
||||
})()
|
||||
@@ -221,10 +223,10 @@ export function SkillsPanel() {
|
||||
<span class="name">${open.scope}/${open.name}</span>
|
||||
<span class="ws">${body.length.toLocaleString()} chars</span>
|
||||
<span class="actions">
|
||||
<button class="btn primary" disabled=${busy} onClick=${save}>Save</button>
|
||||
<button class="btn primary" disabled=${busy} onClick=${save}>${t("common.save")}</button>
|
||||
<button class="btn" disabled=${busy} onClick=${remove}
|
||||
style="border-color:var(--c-err);color:var(--c-err)">Delete</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>← back</button>
|
||||
style="border-color:var(--c-err);color:var(--c-err)">${t("common.delete")}</button>
|
||||
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
${info ? html`<div style="margin-bottom:8px"><span class="pill ok">${info}</span></div>` : null}
|
||||
@@ -236,7 +238,7 @@ export function SkillsPanel() {
|
||||
disabled=${busy}
|
||||
></textarea>
|
||||
<div style="margin-top:8px;color:var(--fg-3);font-size:11.5px">
|
||||
re-loaded on next <code class="mono">/new</code> or session restart
|
||||
${t("skills.reloadHint")}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fmtBytes, fmtNum } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface HealthData {
|
||||
version: string;
|
||||
@@ -14,97 +15,98 @@ interface HealthData {
|
||||
}
|
||||
|
||||
export function SystemPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<HealthData>("/health", 5000);
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading health…</div>`;
|
||||
if (error) return html`<div class="card accent-err">health failed: ${error.message}</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("system.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "health", error: error.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const h = data;
|
||||
const upToDate = h.latestVersion ? h.latestVersion === h.version : null;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<h3 style="margin:0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">Health checks</h3>
|
||||
<h3 style="margin:0;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">${t("system.healthChecks")}</h3>
|
||||
<div class="health-grid">
|
||||
<div class=${`health-item ${upToDate === false ? "warn" : ""}`}>
|
||||
<div class="lbl">
|
||||
version
|
||||
${t("system.version")}
|
||||
${
|
||||
upToDate === null
|
||||
? html`<span class="pill">checking</span>`
|
||||
? html`<span class="pill">${t("system.checking")}</span>`
|
||||
: upToDate
|
||||
? html`<span class="pill ok">● latest</span>`
|
||||
: html`<span class="pill warn">● out of date</span>`
|
||||
? html`<span class="pill ok">${t("system.latest")}</span>`
|
||||
: html`<span class="pill warn">${t("system.outOfDate")}</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="v">${h.version}</div>
|
||||
<div class="meta">${
|
||||
upToDate === null
|
||||
? "version check pending"
|
||||
? t("system.versionPending")
|
||||
: upToDate
|
||||
? "up to date"
|
||||
: `latest: ${h.latestVersion}`
|
||||
? t("system.upToDate")
|
||||
: t("system.latestVer", { version: h.latestVersion ?? "" })
|
||||
}</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">sessions <span class="pill ok">● ok</span></div>
|
||||
<div class="lbl">${t("system.sessions")} <span class="pill ok">${t("system.ok")}</span></div>
|
||||
<div class="v">${fmtBytes(h.sessions.totalBytes)}</div>
|
||||
<div class="meta">${fmtNum(h.sessions.count)} files</div>
|
||||
<div class="meta">${fmtNum(h.sessions.count)} ${t("system.files")}</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">memory <span class="pill ok">● ok</span></div>
|
||||
<div class="lbl">${t("system.memory")} <span class="pill ok">${t("system.ok")}</span></div>
|
||||
<div class="v">${fmtBytes(h.memory.totalBytes)}</div>
|
||||
<div class="meta">${fmtNum(h.memory.fileCount)} files</div>
|
||||
<div class="meta">${fmtNum(h.memory.fileCount)} ${t("system.files")}</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">
|
||||
semantic index
|
||||
${t("system.semanticIndex")}
|
||||
${
|
||||
h.semantic.exists
|
||||
? html`<span class="pill ok">● built</span>`
|
||||
: html`<span class="pill">— none</span>`
|
||||
? html`<span class="pill ok">${t("system.built")}</span>`
|
||||
: html`<span class="pill">${t("system.none")}</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="v">${h.semantic.exists ? fmtBytes(h.semantic.totalBytes) : "—"}</div>
|
||||
<div class="meta">
|
||||
${h.semantic.exists ? `${fmtNum(h.semantic.fileCount)} files` : "run reasonix index to build"}
|
||||
${h.semantic.exists ? `${fmtNum(h.semantic.fileCount)} ${t("system.files")}` : t("system.runIndex")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">usage log <span class="pill ok">● ok</span></div>
|
||||
<div class="lbl">${t("system.usageLog")} <span class="pill ok">${t("system.ok")}</span></div>
|
||||
<div class="v">${fmtBytes(h.usageLog.bytes)}</div>
|
||||
<div class="meta">~/.reasonix/usage.jsonl</div>
|
||||
</div>
|
||||
|
||||
<div class="health-item">
|
||||
<div class="lbl">
|
||||
background jobs
|
||||
${t("system.backgroundJobs")}
|
||||
${
|
||||
h.jobs === null
|
||||
? html`<span class="pill">— no session</span>`
|
||||
? html`<span class="pill">${t("system.noSession")}</span>`
|
||||
: html`<span class="pill ok">● ${fmtNum(h.jobs)}</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="v">${h.jobs === null ? "—" : `${fmtNum(h.jobs)} running`}</div>
|
||||
<div class="meta">${h.jobs === null ? "attach a session to see jobs" : "shell + spawn"}</div>
|
||||
<div class="v">${h.jobs === null ? "—" : t("system.running", { count: fmtNum(h.jobs) })}</div>
|
||||
<div class="meta">${h.jobs === null ? t("system.attachHint") : t("system.shellSpawn")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0">
|
||||
<div class="card-h" style="padding:12px 14px 6px">
|
||||
<span class="title">paths</span>
|
||||
<span class="title">${t("system.paths")}</span>
|
||||
</div>
|
||||
<table class="tbl">
|
||||
<tbody style="font-size:11.5px">
|
||||
<tr><td class="dim" style="padding:5px 14px">home</td><td class="path">${h.reasonixHome}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">sessions</td><td class="path">${h.sessions.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">memory</td><td class="path">${h.memory.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">semantic</td><td class="path">${h.semantic.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">usage</td><td class="path">${h.usageLog.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.home")}</td><td class="path">${h.reasonixHome}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.sessionsPath")}</td><td class="path">${h.sessions.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.memoryPath")}</td><td class="path">${h.memory.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.semanticPath")}</td><td class="path">${h.semantic.path}</td></tr>
|
||||
<tr><td class="dim" style="padding:5px 14px">${t("system.usagePath")}</td><td class="path">${h.usageLog.path}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface ToolEntry {
|
||||
name: string;
|
||||
@@ -21,47 +22,48 @@ interface ToolsError {
|
||||
}
|
||||
|
||||
export function ToolsPanel() {
|
||||
useLang();
|
||||
const { data, error, loading } = usePoll<ToolsData>("/tools", 4000);
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading tools…</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("tools.loading")}</div>`;
|
||||
const e = error as ToolsError | null;
|
||||
if (e?.status === 503) {
|
||||
return html`<div class="card accent-warn">${e.body?.error ?? "live tools view requires an attached session"}</div>`;
|
||||
return html`<div class="card accent-warn">${e.body?.error ?? t("common.loadingFailed", { name: "tools", error: "" })}</div>`;
|
||||
}
|
||||
if (e) return html`<div class="card accent-err">tools failed: ${e.message}</div>`;
|
||||
if (e) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "tools", error: e.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const t = data;
|
||||
const d = data;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">all <span class="ct">${t.total}</span></span>
|
||||
${t.planMode ? html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">plan mode — writes gated</span>` : null}
|
||||
<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}
|
||||
</div>
|
||||
|
||||
${
|
||||
t.tools.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">No tools registered.</div>`
|
||||
d.tools.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">${t("tools.noTools")}</div>`
|
||||
: html`
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>tool</th>
|
||||
<th>flags</th>
|
||||
<th>description</th>
|
||||
<th>${t("tools.colTool")}</th>
|
||||
<th>${t("tools.colFlags")}</th>
|
||||
<th>${t("tools.colDesc")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${t.tools.map(
|
||||
${d.tools.map(
|
||||
(tool) => html`
|
||||
<tr>
|
||||
<td><code class="mono">${tool.name}</code></td>
|
||||
<td>
|
||||
${tool.readOnly
|
||||
? html`<span class="pill ok">read-only</span>`
|
||||
: html`<span class="pill acc">write</span>`}
|
||||
${tool.flattened ? html` <span class="pill">flat</span>` : null}
|
||||
? html`<span class="pill ok">${t("tools.readOnly")}</span>`
|
||||
: html`<span class="pill acc">${t("tools.write")}</span>`}
|
||||
${tool.flattened ? html` <span class="pill">${t("tools.flat")}</span>` : null}
|
||||
</td>
|
||||
<td class="dim">${tool.description ?? ""}</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from "../lib/api.js";
|
||||
import { fmtNum, fmtPct, 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";
|
||||
|
||||
type UPlotInstance = {
|
||||
destroy(): void;
|
||||
@@ -132,6 +133,7 @@ interface UsageSummary {
|
||||
}
|
||||
|
||||
export function UsagePanel() {
|
||||
useLang();
|
||||
const { data: summary, error, loading } = usePoll<UsageSummary>("/usage", 5000);
|
||||
const [series, setSeries] = useState<UsageDay[] | null>(null);
|
||||
|
||||
@@ -145,7 +147,7 @@ export function UsagePanel() {
|
||||
/* keep null; chart hides */
|
||||
}
|
||||
})();
|
||||
const t = setInterval(async () => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const s = await api<{ days?: UsageDay[] }>("/usage/series");
|
||||
if (!cancelled) setSeries(s.days ?? []);
|
||||
@@ -155,13 +157,13 @@ export function UsagePanel() {
|
||||
}, 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading && !summary)
|
||||
return html`<div class="card" style="color:var(--fg-3)">loading usage…</div>`;
|
||||
if (error) return html`<div class="card accent-err">usage failed: ${error.message}</div>`;
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("usage.loading")}</div>`;
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "usage", error: error.message })}</div>`;
|
||||
if (!summary) return null;
|
||||
const u = summary;
|
||||
|
||||
@@ -172,7 +174,7 @@ export function UsagePanel() {
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${u.recordCount.toLocaleString()} records</span>
|
||||
<span class="chip-f active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
|
||||
<span class="chip-f">${u.logSize}</span>
|
||||
</div>
|
||||
|
||||
@@ -181,8 +183,8 @@ export function UsagePanel() {
|
||||
? html`
|
||||
<div class="card" style="padding:18px">
|
||||
<div class="card-h">
|
||||
<span class="title">Daily usage</span>
|
||||
<span class="meta">cost · cache saved · turns</span>
|
||||
<span class="title">${t("usage.dailyUsage")}</span>
|
||||
<span class="meta">${t("usage.dailyMeta")}</span>
|
||||
</div>
|
||||
<${UsageChart} days=${series} />
|
||||
</div>
|
||||
@@ -193,22 +195,21 @@ export function UsagePanel() {
|
||||
${
|
||||
u.recordCount === 0
|
||||
? html`<div class="card" style="color:var(--fg-3);margin-top:8px">
|
||||
No usage data yet — run a turn in <code class="mono">reasonix chat</code> /
|
||||
<code class="mono">code</code> / <code class="mono">run</code> and refresh.
|
||||
${t("usage.noData")}
|
||||
</div>`
|
||||
: html`
|
||||
${sectionH3("Rolling windows")}
|
||||
${sectionH3(t("usage.windows"))}
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>turns</th>
|
||||
<th>cache hit</th>
|
||||
<th>cost (USD)</th>
|
||||
<th>cache saved</th>
|
||||
<th>vs Claude</th>
|
||||
<th>saved</th>
|
||||
<th>${t("usage.colTurns")}</th>
|
||||
<th>${t("usage.colCacheHit")}</th>
|
||||
<th>${t("usage.colCost")}</th>
|
||||
<th>${t("usage.colCacheSaved")}</th>
|
||||
<th>${t("usage.colVsClaude")}</th>
|
||||
<th>${t("usage.colSaved")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -240,13 +241,13 @@ export function UsagePanel() {
|
||||
${
|
||||
u.byModel.length > 0
|
||||
? html`
|
||||
${sectionH3("Most used models")}
|
||||
${sectionH3(t("usage.mostUsed"))}
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>model</th>
|
||||
<th>turns</th>
|
||||
<th>${t("usage.colModel")}</th>
|
||||
<th>${t("usage.colTurns")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -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,9 +386,19 @@ 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>
|
||||
@@ -366,18 +407,77 @@ npx reasonix diff \
|
||||
<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.23.1",
|
||||
"version": "0.28.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,23 +72,29 @@
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"ink": "^5.1.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"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",
|
||||
"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",
|
||||
"@vitest/coverage-v8": "^2.1.5",
|
||||
"esbuild": "^0.21.5",
|
||||
"highlight.js": "^11.10.0",
|
||||
"htm": "^3.1.1",
|
||||
"marked": "^12.0.2",
|
||||
"ink": "^5.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"preact": "^10.22.0",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"tsup": "^8.3.5",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { loadApiKey, readConfig, searchEnabled } 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 { type McpClientHost, bridgeMcpTools } from "../../mcp/registry.js";
|
||||
@@ -9,6 +10,7 @@ import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { SseTransport } from "../../mcp/sse.js";
|
||||
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
|
||||
import { StreamableHttpTransport } from "../../mcp/streamable-http.js";
|
||||
import { buildMcpServerSummary } from "../../mcp/summary.js";
|
||||
import {
|
||||
deleteSession,
|
||||
listSessionsForWorkspace,
|
||||
@@ -34,6 +36,220 @@ 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)
|
||||
: "";
|
||||
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;
|
||||
@@ -95,6 +311,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({
|
||||
@@ -104,6 +322,7 @@ function Root({
|
||||
mcpServers,
|
||||
progressSink,
|
||||
showPicker,
|
||||
mcpRuntime,
|
||||
...appProps
|
||||
}: RootProps) {
|
||||
const [key, setKey] = useState<string | undefined>(initialKey);
|
||||
@@ -175,6 +394,7 @@ function Root({
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
mcpRuntime={mcpRuntime}
|
||||
progressSink={progressSink}
|
||||
codeMode={appProps.codeMode}
|
||||
noDashboard={appProps.noDashboard}
|
||||
@@ -189,10 +409,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
|
||||
@@ -203,111 +419,25 @@ 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";
|
||||
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 });
|
||||
const 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({
|
||||
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.
|
||||
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;
|
||||
}
|
||||
const mcpSpecs = runtime.specs();
|
||||
const mcpServers = runtime.summaries();
|
||||
|
||||
// Register web search/fetch tools unless explicitly disabled. DDG
|
||||
// backs them with no key required; the model invokes them whenever
|
||||
@@ -362,6 +492,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
mcpRuntime={runtime}
|
||||
progressSink={progressSink}
|
||||
showPicker={showPicker}
|
||||
{...opts}
|
||||
@@ -373,6 +504,6 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
try {
|
||||
await waitUntilExit();
|
||||
} finally {
|
||||
for (const c of clients) await c.close();
|
||||
await runtime.closeAll();
|
||||
}
|
||||
}
|
||||
@@ -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,237 @@
|
||||
/** `reasonix mcp browse` — Ink TUI for the MCP marketplace. Lazy-loads pages on scroll. */
|
||||
|
||||
import { Box, Text, render, useApp, useInput } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
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();
|
||||
}
|
||||
@@ -35,6 +35,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}`);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
tools = new ToolRegistry();
|
||||
for (const raw of requestedSpecs) {
|
||||
let label = "anon";
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
const spec = parseMcpSpec(raw);
|
||||
label = spec.name ?? "anon";
|
||||
@@ -108,7 +109,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
: spec.transport === "streamable-http"
|
||||
? new StreamableHttpTransport({ url: spec.url })
|
||||
: new StdioTransport({ command: spec.command, args: spec.args });
|
||||
const mcp = new McpClient({ transport });
|
||||
mcp = new McpClient({ transport });
|
||||
await mcp.initialize();
|
||||
const bridge = await bridgeMcpTools(mcp, {
|
||||
registry: tools,
|
||||
@@ -134,6 +135,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
// one-shot `run` invocation with a broken MCP server otherwise
|
||||
// fails the whole run over a side-concern tool the task might
|
||||
// not even touch.
|
||||
await mcp?.close().catch(() => undefined);
|
||||
process.stderr.write(
|
||||
`${formatMcpLifecycleEvent({ state: "failed", name: label, reason: (err as Error).message })}\n → run \`reasonix setup\` to remove broken entries from your saved config.\n`,
|
||||
);
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { Command } from "commander";
|
||||
import { readConfig } from "../config.js";
|
||||
import { t } from "../i18n/index.js";
|
||||
import { VERSION } from "../index.js";
|
||||
import { listSessions } from "../memory/session.js";
|
||||
import { applyMemoryStack } from "../memory/user.js";
|
||||
import { ESCALATION_CONTRACT } from "../prompt-fragments.js";
|
||||
import { chatCommand } from "./commands/chat.js";
|
||||
import { codeCommand } from "./commands/code.js";
|
||||
import { commitCommand } from "./commands/commit.js";
|
||||
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 { replayCommand } from "./commands/replay.js";
|
||||
import { runCommand } from "./commands/run.js";
|
||||
import { sessionsCommand } from "./commands/sessions.js";
|
||||
import { setupCommand } from "./commands/setup.js";
|
||||
import { statsCommand } from "./commands/stats.js";
|
||||
import { updateCommand } from "./commands/update.js";
|
||||
import { versionCommand } from "./commands/version.js";
|
||||
import { resolveContinueFlag, resolveDefaults } from "./resolve.js";
|
||||
|
||||
const DEFAULT_SYSTEM = `You are Reasonix, a helpful DeepSeek-powered assistant. Be concise and accurate. Use tools when available.
|
||||
|
||||
@@ -43,6 +28,24 @@ Your training data has a cutoff. When an answer's correctness depends on somethi
|
||||
The signal isn't a topic list — it's: "if I'm wrong about this, is it because reality moved on?". If yes, ground the answer in fresh evidence; if no (definitions, mechanisms, well-established APIs), answer from memory.
|
||||
|
||||
${ESCALATION_CONTRACT}`;
|
||||
import { codeCommand } from "./commands/code.js";
|
||||
import { commitCommand } from "./commands/commit.js";
|
||||
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 { 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";
|
||||
import { setupCommand } from "./commands/setup.js";
|
||||
import { statsCommand } from "./commands/stats.js";
|
||||
import { updateCommand } from "./commands/update.js";
|
||||
import { versionCommand } from "./commands/version.js";
|
||||
import { resolveContinueFlag, resolveDefaults } from "./resolve.js";
|
||||
|
||||
/** Lenient: malformed → undefined (no cap) so a bad flag doesn't abort launch. */
|
||||
function parseBudgetFlag(raw: number | undefined): number | undefined {
|
||||
@@ -59,12 +62,9 @@ function parseBudgetFlag(raw: number | undefined): number | undefined {
|
||||
const program = new Command();
|
||||
program
|
||||
.name("reasonix")
|
||||
.description("DeepSeek-native agent framework — built for cache hits and cheap tokens.")
|
||||
.description(t("cli.description"))
|
||||
.version(VERSION)
|
||||
.option(
|
||||
"-c, --continue",
|
||||
"Resume the most recently used chat session without showing the picker.",
|
||||
);
|
||||
.option("-c, --continue", t("cli.continue"));
|
||||
|
||||
// `reasonix` with no subcommand → launch the friendliest flow.
|
||||
// First run (no config yet) → interactive setup wizard.
|
||||
@@ -97,42 +97,24 @@ program.action(async (opts: { continue?: boolean }) => {
|
||||
|
||||
program
|
||||
.command("setup")
|
||||
.description("Interactive wizard — API key, preset, MCP servers. Re-run any time to reconfigure.")
|
||||
.description(t("cli.setup"))
|
||||
.action(async () => {
|
||||
await setupCommand({});
|
||||
});
|
||||
|
||||
program
|
||||
.command("code [dir]")
|
||||
.description(
|
||||
"Code-editing chat — filesystem tools rooted at <dir> (default: cwd), coding system prompt, v4-flash baseline. Model proposes SEARCH/REPLACE blocks; Reasonix applies them to disk. Use /preset pro or /pro to lock v4-pro on hard tasks.",
|
||||
)
|
||||
.option("-m, --model <id>", "Override default model (v4-flash)")
|
||||
.option("--no-session", "Disable session persistence for this run")
|
||||
.option("-r, --resume", "Skip the session picker — always continue prior messages")
|
||||
.option("-n, --new", "Skip the session picker — always wipe prior messages and start fresh")
|
||||
.option("--transcript <path>", "Write a JSONL transcript to this path")
|
||||
.option(
|
||||
"--harvest",
|
||||
"Opt-in Pillar-2 plan-state extraction. Adds one flash call per turn; off by default (no preset enables it).",
|
||||
)
|
||||
.option(
|
||||
"--budget <usd>",
|
||||
"Soft USD cap on session spend. Off by default. Warns at 80%, refuses next turn at 100%. Mid-session: /budget <usd> or /budget off.",
|
||||
(v) => Number.parseFloat(v),
|
||||
)
|
||||
.option(
|
||||
"--no-dashboard",
|
||||
"Suppress the auto-launched embedded web dashboard. Default behavior boots it on TUI mount and shows the URL in the status bar (clickable in OSC-8-aware terminals).",
|
||||
)
|
||||
.option(
|
||||
"--system-append <prompt>",
|
||||
"Append instructions to the code system prompt. Does NOT replace the default prompt — adds after it.",
|
||||
)
|
||||
.option(
|
||||
"--system-append-file <path>",
|
||||
"Append file contents to the code system prompt. Does NOT replace the default prompt. UTF-8, relative to cwd or absolute.",
|
||||
)
|
||||
.description(t("cli.code"))
|
||||
.option("-m, --model <id>", t("ui.modelOverride"))
|
||||
.option("--no-session", t("ui.noSession"))
|
||||
.option("-r, --resume", t("ui.resumeHint"))
|
||||
.option("-n, --new", t("ui.newHint"))
|
||||
.option("--transcript <path>", t("ui.transcriptHint"))
|
||||
.option("--harvest", t("ui.harvestHint"))
|
||||
.option("--budget <usd>", t("ui.budgetHint"), (v) => Number.parseFloat(v))
|
||||
.option("--no-dashboard", t("ui.noDashboard"))
|
||||
.option("--system-append <prompt>", t("ui.systemAppendHint"))
|
||||
.option("--system-append-file <path>", t("ui.systemAppendFileHint"))
|
||||
.action(async (dir: string | undefined, opts) => {
|
||||
await codeCommand({
|
||||
dir,
|
||||
@@ -151,51 +133,28 @@ program
|
||||
|
||||
program
|
||||
.command("chat")
|
||||
.description("Interactive Ink TUI with live cache/cost panel.")
|
||||
.option("-m, --model <id>", "DeepSeek model id (overrides preset)")
|
||||
.option("-s, --system <prompt>", "System prompt (pinned in the immutable prefix)", DEFAULT_SYSTEM)
|
||||
.option("--transcript <path>", "Write a JSONL transcript to this path")
|
||||
.option(
|
||||
"--preset <name>",
|
||||
"Model bundle. One of: auto (flash → pro on hard turns, default), flash (always flash), pro (always pro). Overrides config.preset.",
|
||||
)
|
||||
.option(
|
||||
"--harvest",
|
||||
"Opt-in Pillar-2 plan-state extraction. Off by default — no preset enables it.",
|
||||
)
|
||||
.option(
|
||||
"--branch <n>",
|
||||
"Self-consistency: run N parallel samples per turn (N× cost). Manual only — never auto-enabled.",
|
||||
(v) => Number.parseInt(v, 10),
|
||||
)
|
||||
.option(
|
||||
"--budget <usd>",
|
||||
"Soft USD cap on session spend. Off by default. Warns at 80%, refuses next turn at 100%. Mid-session: /budget <usd> or /budget off.",
|
||||
(v) => Number.parseFloat(v),
|
||||
)
|
||||
.option("--session <name>", "Use a named session (default: from config, usually 'default').")
|
||||
.option("--no-session", "Disable session persistence for this run (ephemeral chat)")
|
||||
.option("-r, --resume", "Skip the session picker — always continue prior messages")
|
||||
.option(
|
||||
"-c, --continue",
|
||||
"Resume the most-recently-used session (any name) without showing the picker.",
|
||||
)
|
||||
.option("-n, --new", "Skip the session picker — always wipe prior messages and start fresh")
|
||||
.description(t("cli.chat"))
|
||||
.option("-m, --model <id>", t("ui.modelIdHint"))
|
||||
.option("-s, --system <prompt>", t("ui.systemPromptHint"), DEFAULT_SYSTEM)
|
||||
.option("--transcript <path>", t("ui.transcriptHint"))
|
||||
.option("--preset <name>", t("ui.presetHint"))
|
||||
.option("--harvest", t("ui.harvestOptInHint"))
|
||||
.option("--branch <n>", t("ui.branchHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--budget <usd>", t("ui.budgetHint"), (v) => Number.parseFloat(v))
|
||||
.option("--session <name>", t("ui.sessionNameHint"))
|
||||
.option("--no-session", t("ui.ephemeralHint"))
|
||||
.option("-r, --resume", t("ui.resumeHint"))
|
||||
.option("-c, --continue", t("cli.continue"))
|
||||
.option("-n, --new", t("ui.newHint"))
|
||||
.option(
|
||||
"--mcp <spec>",
|
||||
'MCP server spec; repeatable. "name=cmd args...", "cmd args...", or a URL (http/https → SSE transport). Overrides config.mcp when provided.',
|
||||
t("ui.mcpSpecHint"),
|
||||
(value: string, previous: string[] = []) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.option(
|
||||
"--mcp-prefix <str>",
|
||||
"Global prefix applied to every MCP tool (only honored when no per-spec name is set; avoids collisions with a single anonymous server)",
|
||||
)
|
||||
.option("--no-config", "Ignore `~/.reasonix/config.json` — useful for CI or reproducing issues")
|
||||
.option(
|
||||
"--no-dashboard",
|
||||
"Suppress the auto-launched embedded web dashboard. Default behavior boots it on TUI mount and shows the URL in the status bar (clickable in OSC-8-aware terminals).",
|
||||
)
|
||||
.option("--mcp-prefix <str>", t("ui.mcpPrefixHint"))
|
||||
.option("--no-config", t("ui.noConfigHint"))
|
||||
.option("--no-dashboard", t("ui.noDashboard"))
|
||||
.action(async (opts) => {
|
||||
const defaults = resolveDefaults({
|
||||
model: opts.model,
|
||||
@@ -236,33 +195,22 @@ program
|
||||
|
||||
program
|
||||
.command("run <task>")
|
||||
.description("Run a single task non-interactively, streaming output.")
|
||||
.option("-m, --model <id>", "DeepSeek model id (overrides preset)")
|
||||
.option("-s, --system <prompt>", "System prompt", DEFAULT_SYSTEM)
|
||||
.option("--preset <name>", "Model bundle: auto | flash | pro (default: auto)")
|
||||
.option("--harvest", "Extract typed plan state from R1 reasoning (Pillar 2)")
|
||||
.option(
|
||||
"--branch <n>",
|
||||
"Self-consistency: run N parallel samples per turn and pick the most confident",
|
||||
(v) => Number.parseInt(v, 10),
|
||||
)
|
||||
.option(
|
||||
"--budget <usd>",
|
||||
"Soft USD cap on session spend. Off by default. Refuses to start a new turn once cumulative cost ≥ cap.",
|
||||
(v) => Number.parseFloat(v),
|
||||
)
|
||||
.option("--transcript <path>", "Write a JSONL transcript to this path for replay/diff")
|
||||
.description(t("cli.run"))
|
||||
.option("-m, --model <id>", t("ui.modelIdHint"))
|
||||
.option("-s, --system <prompt>", t("ui.systemPromptHint"), DEFAULT_SYSTEM)
|
||||
.option("--preset <name>", t("ui.presetHintShort"))
|
||||
.option("--harvest", t("ui.harvestHintShort"))
|
||||
.option("--branch <n>", t("ui.branchHintShort"), (v) => Number.parseInt(v, 10))
|
||||
.option("--budget <usd>", t("ui.budgetHintShort"), (v) => Number.parseFloat(v))
|
||||
.option("--transcript <path>", t("ui.transcriptHintShort"))
|
||||
.option(
|
||||
"--mcp <spec>",
|
||||
'MCP server spec; repeatable. "name=cmd args...", "cmd args...", or a URL (http/https → SSE).',
|
||||
t("ui.mcpSpecHintShort"),
|
||||
(value: string, previous: string[] = []) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.option(
|
||||
"--mcp-prefix <str>",
|
||||
"Global prefix (only honored when no per-spec name is set; for a single anonymous server)",
|
||||
)
|
||||
.option("--no-config", "Ignore `~/.reasonix/config.json` — useful for CI or reproducing issues")
|
||||
.option("--mcp-prefix <str>", t("ui.mcpPrefixHintShort"))
|
||||
.option("--no-config", t("ui.noConfigHint"))
|
||||
.action(async (task: string, opts) => {
|
||||
const defaults = resolveDefaults({
|
||||
model: opts.model,
|
||||
@@ -287,58 +235,52 @@ program
|
||||
|
||||
program
|
||||
.command("stats [transcript]")
|
||||
.description(
|
||||
"Show usage dashboard (today / week / month / all-time · turns · cache hit · cost · savings vs Claude). " +
|
||||
"Pass a transcript path to fall back to the per-file summary (assistant turns + tool calls).",
|
||||
)
|
||||
.description(t("cli.stats"))
|
||||
.action((transcript: string | undefined) => {
|
||||
statsCommand({ transcript });
|
||||
});
|
||||
|
||||
program
|
||||
.command("doctor")
|
||||
.description(
|
||||
"One-command health check — API key, config, /user/balance reachability, tokenizer, sessions, hooks, Ollama (if used), project markers. Exit 1 on any fail; 0 on warn / clean.",
|
||||
)
|
||||
.description(t("cli.doctor"))
|
||||
.action(async () => {
|
||||
await doctorCommand();
|
||||
});
|
||||
|
||||
program
|
||||
.command("commit")
|
||||
.description(
|
||||
"Draft a commit message from the staged diff (or working tree, if nothing staged), matching your repo's recent commit style. Review interactively before it lands.",
|
||||
)
|
||||
.option("-m, --model <id>", "Override the default model (deepseek-v4-flash)")
|
||||
.option(
|
||||
"-y, --yes",
|
||||
"Skip the [a]ccept / [r]egenerate prompt and commit the first draft. Useful in scripts.",
|
||||
)
|
||||
.description(t("cli.commit"))
|
||||
.option("-m, --model <id>", t("ui.modelOverrideFlash"))
|
||||
.option("-y, --yes", t("ui.skipConfirmHint"))
|
||||
.action(async (opts) => {
|
||||
await commitCommand({ model: opts.model, yes: !!opts.yes });
|
||||
});
|
||||
|
||||
program
|
||||
.command("sessions [name]")
|
||||
.description("List saved chat sessions, or inspect one by name.")
|
||||
.option("-v, --verbose", "Include system prompts + tool-call metadata when inspecting")
|
||||
.description(t("cli.sessions"))
|
||||
.option("-v, --verbose", t("ui.verboseHint"))
|
||||
.action((name: string | undefined, opts) => {
|
||||
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(
|
||||
"Pretty-print the kernel event-log sidecar (~/.reasonix/sessions/<name>.events.jsonl) — every typed Event the session produced.",
|
||||
)
|
||||
.option("--type <type>", "Show only events of this type (e.g., tool.intent)")
|
||||
.option("--since <id>", "Show only events with id >= N", (v) => Number.parseInt(v, 10))
|
||||
.option("--tail <n>", "Show only the last N events", (v) => Number.parseInt(v, 10))
|
||||
.option("--json", "Emit raw JSONL passthrough instead of formatted lines (for jq pipelines)")
|
||||
.option(
|
||||
"--projection",
|
||||
"Replace the listing with the final reduced ProjectionSet (conversation, budget, plan, …)",
|
||||
)
|
||||
.description(t("cli.events"))
|
||||
.option("--type <type>", t("ui.eventTypeHint"))
|
||||
.option("--since <id>", t("ui.eventSinceHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--tail <n>", t("ui.eventTailHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--json", t("ui.jsonHint"))
|
||||
.option("--projection", t("ui.projectionHint"))
|
||||
.action((name: string, opts) => {
|
||||
eventsCommand({
|
||||
name,
|
||||
@@ -352,12 +294,10 @@ program
|
||||
|
||||
program
|
||||
.command("replay <transcript>")
|
||||
.description(
|
||||
"Interactive Ink TUI to scrub through a transcript + rebuild its session summary (cost, cache, prefix stability). No API calls.",
|
||||
)
|
||||
.option("--print", "Dump to stdout instead of mounting the TUI (auto when piped)")
|
||||
.option("--head <n>", "stdout mode only — show first N records", (v) => Number.parseInt(v, 10))
|
||||
.option("--tail <n>", "stdout mode only — show last N records", (v) => Number.parseInt(v, 10))
|
||||
.description(t("cli.replay"))
|
||||
.option("--print", t("ui.printHint"))
|
||||
.option("--head <n>", t("ui.headHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--tail <n>", t("ui.tailHint"), (v) => Number.parseInt(v, 10))
|
||||
.action(async (transcript: string, opts) => {
|
||||
await replayCommand({
|
||||
path: transcript,
|
||||
@@ -369,14 +309,12 @@ program
|
||||
|
||||
program
|
||||
.command("diff <a> <b>")
|
||||
.description(
|
||||
"Compare two transcripts in a split-pane Ink TUI (default) or stdout table. Use n/N to jump across divergences.",
|
||||
)
|
||||
.option("--md <path>", "Write a markdown report (blog-ready) to this path")
|
||||
.option("--print", "Force stdout table instead of the TUI (auto when piped)")
|
||||
.option("--tui", "Force the TUI even when piped (rare)")
|
||||
.option("--label-a <label>", "Display label for transcript A (default: filename)")
|
||||
.option("--label-b <label>", "Display label for transcript B (default: filename)")
|
||||
.description(t("cli.diff"))
|
||||
.option("--md <path>", t("ui.mdReportHint"))
|
||||
.option("--print", t("ui.printHintTable"))
|
||||
.option("--tui", t("ui.tuiHint"))
|
||||
.option("--label-a <label>", t("ui.labelAHint"))
|
||||
.option("--label-b <label>", t("ui.labelBHint"))
|
||||
.action(async (a: string, b: string, opts) => {
|
||||
await diffCommand({
|
||||
a,
|
||||
@@ -389,58 +327,116 @@ program
|
||||
});
|
||||
});
|
||||
|
||||
const mcp = program
|
||||
.command("mcp")
|
||||
.description("Model Context Protocol helpers — discover servers, test your setup.");
|
||||
const mcp = program.command("mcp").description(t("cli.mcp"));
|
||||
|
||||
mcp
|
||||
.command("list")
|
||||
.description("Show a curated catalog of popular MCP servers with ready-to-use --mcp commands.")
|
||||
.option("--json", "Emit the catalog as JSON instead of the human-readable table")
|
||||
.action((opts) => {
|
||||
mcpListCommand({ json: !!opts.json });
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("inspect <spec>")
|
||||
.description(
|
||||
'Connect to one MCP server and print its server info + tools/resources/prompts. <spec> takes the same forms as --mcp ("name=cmd args", "cmd args", or an SSE URL).',
|
||||
)
|
||||
.option("--json", "Emit the full inspection report as JSON instead of the human-readable summary")
|
||||
.action(async (spec: string, opts) => {
|
||||
.description(t("ui.mcpListDescription"))
|
||||
.option("--json", t("ui.jsonHintCatalog"))
|
||||
.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 mcpInspectCommand({ spec, json: !!opts.json });
|
||||
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 inspect failed: ${(err as Error).message}\n`);
|
||||
process.stderr.write(`mcp list failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.command("version").description("Print Reasonix version.").action(versionCommand);
|
||||
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
|
||||
.command("inspect <spec>")
|
||||
.description(t("ui.mcpInspectDescription"))
|
||||
.option("--json", t("ui.jsonHintReport"))
|
||||
.action(async (spec: string, opts) => {
|
||||
try {
|
||||
await mcpInspectCommand({ spec, json: !!opts.json });
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp inspect failed: ${formatMcpInspectFailure(err)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.command("version").description(t("cli.version")).action(versionCommand);
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.description(
|
||||
"Check the npm registry for a newer Reasonix and install it. Detects npx vs global install; for npx users, prints a cache-refresh hint instead of running `npm i -g`.",
|
||||
)
|
||||
.option("--dry-run", "Print the plan without executing the install")
|
||||
.description(t("cli.update"))
|
||||
.option("--dry-run", t("ui.dryRunHint"))
|
||||
.action(async (opts: { dryRun?: boolean }) => {
|
||||
await updateCommand({ dryRun: !!opts.dryRun });
|
||||
});
|
||||
|
||||
program
|
||||
.command("index")
|
||||
.description(
|
||||
"Build (or incrementally refresh) a local semantic search index for the project so `reasonix code` can answer 'where do we…' questions by meaning, not just by token. Uses Ollama as the embedding backend; missing daemon / model is offered to the user with a confirm prompt.",
|
||||
)
|
||||
.option("--rebuild", "Wipe and rebuild from scratch")
|
||||
.option("--model <name>", "Embedding model (default: nomic-embed-text)")
|
||||
.option("--dir <path>", "Project root to index (default: cwd)")
|
||||
.option("--ollama-url <url>", "Override Ollama base URL (default: http://localhost:11434)")
|
||||
.option(
|
||||
"-y, --yes",
|
||||
"Skip preflight prompts — auto-start the daemon and pull the model if missing (use in scripts)",
|
||||
)
|
||||
.description(t("cli.index"))
|
||||
.option("--rebuild", t("ui.rebuildHint"))
|
||||
.option("--model <name>", t("ui.embedModelHint"))
|
||||
.option("--dir <path>", t("ui.projectDirHint"))
|
||||
.option("--ollama-url <url>", t("ui.ollamaUrlHint"))
|
||||
.option("-y, --yes", t("ui.skipPromptsHint"))
|
||||
.action(
|
||||
async (opts: {
|
||||
rebuild?: boolean;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Text, useInput } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useRef } from "react";
|
||||
import { FG } from "./theme/tokens.js";
|
||||
|
||||
export interface MaskedInputProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
onSubmit: (final: string) => void;
|
||||
mask?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Windows ConPTY splits bracketed-paste wrappers across stdin chunks; Ink's parser sees them as printable `[`, `2`, `0`, `0`, `~` and they leak into the buffer. Strip them at the input boundary and again at submit. */
|
||||
function stripPasteMarkers(s: string): string {
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC (0x1b) is exactly what we're stripping — bracketed-paste wrappers and stray escape bytes leaked from Ink's parser.
|
||||
return s.replace(/\u001b?\[20[01]~/g, "").replace(/\u001b/g, "");
|
||||
}
|
||||
|
||||
export function MaskedInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
mask = "•",
|
||||
placeholder = "",
|
||||
}: MaskedInputProps): React.ReactElement {
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.return) {
|
||||
onSubmit(stripPasteMarkers(valueRef.current));
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
if (valueRef.current.length === 0) return;
|
||||
const next = valueRef.current.slice(0, -1);
|
||||
valueRef.current = next;
|
||||
onChange(next);
|
||||
return;
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && !key.escape) {
|
||||
const cleaned = stripPasteMarkers(input);
|
||||
if (cleaned.length === 0) return;
|
||||
const next = stripPasteMarkers(valueRef.current + cleaned);
|
||||
valueRef.current = next;
|
||||
onChange(next);
|
||||
}
|
||||
});
|
||||
|
||||
if (value.length === 0) {
|
||||
if (placeholder.length === 0) {
|
||||
return <Text inverse> </Text>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Text inverse>{placeholder[0]}</Text>
|
||||
<Text color={FG.faint}>{placeholder.slice(1)}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>{mask.repeat(value.length)}</Text>
|
||||
<Text inverse> </Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Box, Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { toggleMcpDisabled } from "./mcp-disable.js";
|
||||
import { healthBadge } from "./mcp-health.js";
|
||||
import { type ApplyAppend, kickOffMcpReconnect } from "./mcp-reconnect-kickoff.js";
|
||||
import type { McpServerSummary } from "./slash/types.js";
|
||||
import { COLOR } from "./theme.js";
|
||||
@@ -106,13 +107,6 @@ function ServerRow({ server, active }: { server: McpServerSummary; active: boole
|
||||
);
|
||||
}
|
||||
|
||||
function healthBadge(elapsedMs: number): { glyph: string; label: string; color: string } {
|
||||
if (elapsedMs === 0) return { glyph: "✗", label: "no inspect data", color: COLOR.err };
|
||||
if (elapsedMs < 500) return { glyph: "●", label: `healthy · ${elapsedMs}ms`, color: COLOR.ok };
|
||||
if (elapsedMs < 3000) return { glyph: "◌", label: `slow · ${elapsedMs}ms`, color: COLOR.warn };
|
||||
return { glyph: "✗", label: `very slow · ${elapsedMs}ms`, color: COLOR.err };
|
||||
}
|
||||
|
||||
function capabilityList(s: McpServerSummary): string {
|
||||
const caps: string[] = ["tools/list", "tools/call"];
|
||||
if (s.report.resources.supported) caps.push("resources/list");
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** `/mcp` slash modal — single hub with two tabs: Live (attached servers) + Marketplace (registry). */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import { McpBrowser } from "./McpBrowser.js";
|
||||
import { McpMarketplace } from "./McpMarketplace.js";
|
||||
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 }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function McpHub({
|
||||
initialTab,
|
||||
liveServers,
|
||||
configPath,
|
||||
onClose,
|
||||
postInfo,
|
||||
applyAppend,
|
||||
reloadMcp,
|
||||
}: 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} />
|
||||
)}
|
||||
</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,319 @@
|
||||
/** `/mcp browse` modal — registry marketplace inside the chat session. */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import {
|
||||
type RegistryHandle,
|
||||
fetchSmitheryDetail,
|
||||
loadMorePages,
|
||||
openRegistry,
|
||||
specStringFor,
|
||||
} from "../../mcp/registry-fetch.js";
|
||||
import type { RegistryEntry } from "../../mcp/registry-types.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 }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
handle: RegistryHandle | null;
|
||||
loading: boolean;
|
||||
query: string;
|
||||
selected: number;
|
||||
status: string;
|
||||
/** specs currently in config.mcp[] — refreshed after install/uninstall. */
|
||||
installedSpecs: 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 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 }: 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 installOrToggle = useCallback(
|
||||
async (entry: RegistryEntry) => {
|
||||
const installed = isInstalled(state.installedSpecs, entry);
|
||||
if (installed) {
|
||||
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`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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}` }));
|
||||
}
|
||||
},
|
||||
[state.installedSpecs, postInfo, reloadMcp],
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { CARD, FG } from "./theme/tokens.js";
|
||||
import { useTick } from "./ticker.js";
|
||||
|
||||
export interface PlanRefineInputProps {
|
||||
mode: "approve" | "refine" | "checkpoint-revise" | "choice-custom";
|
||||
mode: "approve" | "refine" | "reject" | "checkpoint-revise" | "choice-custom";
|
||||
onSubmit: (feedback: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -37,6 +37,15 @@ const MODES: Record<PlanRefineInputProps["mode"], ModeMeta> = {
|
||||
hint: "Describe what's wrong or missing, or answer questions the plan raised.",
|
||||
blankHint: " (Enter with blank = ask the model to list concrete questions.)",
|
||||
},
|
||||
reject: {
|
||||
title: "rejecting — tell the model why (optional)",
|
||||
glyph: "✗",
|
||||
tone: "error",
|
||||
cursorColor: CARD.error.color,
|
||||
hint: "Say what the model got wrong about your goal, or what you actually want instead.",
|
||||
blankHint:
|
||||
" (Enter with blank = cancel without explanation; the model will ask what you want.)",
|
||||
},
|
||||
"checkpoint-revise": {
|
||||
title: "revising — what should change before the next step?",
|
||||
glyph: "✎",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -152,6 +153,8 @@ 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,7 +1,7 @@
|
||||
import { Box, Text, useApp } from "ink";
|
||||
import TextInput from "ink-text-input";
|
||||
import React, { useState } from "react";
|
||||
import { defaultConfigPath, isPlausibleKey, redactKey, saveApiKey } from "../../config.js";
|
||||
import { MaskedInput } from "./MaskedInput.js";
|
||||
import { COLOR, GLYPH, GRADIENT } from "./theme.js";
|
||||
|
||||
export interface SetupProps {
|
||||
@@ -63,7 +63,7 @@ export function Setup({ onReady }: SetupProps) {
|
||||
<Text bold color={COLOR.primary}>
|
||||
{" › "}
|
||||
</Text>
|
||||
<TextInput
|
||||
<MaskedInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@@ -35,7 +35,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 +51,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 +80,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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { t } from "../../i18n/index.js";
|
||||
import type { SlashCommandSpec } from "./slash.js";
|
||||
import { COLOR, GLYPH } from "./theme.js";
|
||||
|
||||
@@ -82,6 +83,9 @@ export function SlashSuggestions({
|
||||
function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelected: boolean }) {
|
||||
const name = `/${spec.cmd}`;
|
||||
const argsSuffix = spec.argsHint ? spec.argsHint : "";
|
||||
const key = `slash.${spec.cmd}.description`;
|
||||
const translated = t(key);
|
||||
const summary = translated === key ? spec.summary : translated;
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
|
||||
@@ -93,7 +97,7 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
|
||||
<Text dimColor>{argsSuffix.padEnd(14)}</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={isSelected ? COLOR.user : COLOR.info} dimColor={!isSelected}>
|
||||
{spec.summary}
|
||||
{summary}
|
||||
</Text>
|
||||
</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}` : "";
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/** Empty-session welcome card — sharp ASCII box + tagline + 4 starter slash commands. */
|
||||
/** Empty-session welcome card — REASONIX × 🐋 DeepSeek brand row + tagline + starter slash commands. */
|
||||
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
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 { t } from "../../i18n/index.js";
|
||||
import { FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export interface WelcomeBannerProps {
|
||||
@@ -10,135 +11,70 @@ export interface WelcomeBannerProps {
|
||||
inCodeMode?: boolean;
|
||||
/** 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. */
|
||||
languageVersion?: number;
|
||||
}
|
||||
|
||||
const TAGLINE_CHAT = "DeepSeek-native agent";
|
||||
const TAGLINE_CODE = "DeepSeek-native coding agent";
|
||||
const TAGLINE_SUB = "cache-first · flash-first";
|
||||
const HINTS = ["/help", "/init", "/memory", "/cost"] as const;
|
||||
const BOX_INNER_WIDTH = 35;
|
||||
|
||||
export function WelcomeBanner({
|
||||
inCodeMode,
|
||||
dashboardUrl,
|
||||
}: WelcomeBannerProps): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const tagline = inCodeMode ? TAGLINE_CODE : TAGLINE_CHAT;
|
||||
const boxWidth = BOX_INNER_WIDTH + 2;
|
||||
const boxIndent = Math.max(2, Math.floor((cols - boxWidth) / 2));
|
||||
const top = `╔${"═".repeat(BOX_INNER_WIDTH)}╗`;
|
||||
const bot = `╚${"═".repeat(BOX_INNER_WIDTH)}╝`;
|
||||
const hintsRow = HINTS.join(" · ");
|
||||
const hintsIndent = Math.max(2, Math.floor((cols - hintsRow.length) / 2));
|
||||
const startTextRaw = "type a message to start your session";
|
||||
const startIndent = Math.max(2, Math.floor((cols - startTextRaw.length) / 2));
|
||||
const tagline = inCodeMode ? t("ui.taglineCode") : t("ui.taglineChat");
|
||||
const taglineSub = t("ui.taglineSub");
|
||||
const startTextRaw = t("ui.startSessionHint");
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<BoxRow indent={boxIndent}>
|
||||
<Text color={TONE.brand}>{top}</Text>
|
||||
</BoxRow>
|
||||
<BoxRow indent={boxIndent}>
|
||||
<BoxLine pad={BOX_INNER_WIDTH}>{""}</BoxLine>
|
||||
</BoxRow>
|
||||
<BoxRow indent={boxIndent}>
|
||||
<BoxLine pad={BOX_INNER_WIDTH}>
|
||||
<Text bold color={TONE.brand}>
|
||||
{centerInside("◈ REASONIX", BOX_INNER_WIDTH)}
|
||||
<Box flexDirection="column" alignItems="center" marginY={1}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
borderStyle="round"
|
||||
borderColor={TONE.brand}
|
||||
paddingX={4}
|
||||
paddingY={1}
|
||||
>
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<Text color={TONE.brand} bold>
|
||||
{"REASONIX"}
|
||||
</Text>
|
||||
</BoxLine>
|
||||
</BoxRow>
|
||||
<BoxRow indent={boxIndent}>
|
||||
<BoxLine pad={BOX_INNER_WIDTH}>{""}</BoxLine>
|
||||
</BoxRow>
|
||||
<BoxRow indent={boxIndent}>
|
||||
<BoxLine pad={BOX_INNER_WIDTH}>
|
||||
<Text color={FG.sub}>{centerInside(tagline, BOX_INNER_WIDTH)}</Text>
|
||||
</BoxLine>
|
||||
</BoxRow>
|
||||
<BoxRow indent={boxIndent}>
|
||||
<BoxLine pad={BOX_INNER_WIDTH}>
|
||||
<Text color={FG.meta}>{centerInside(TAGLINE_SUB, BOX_INNER_WIDTH)}</Text>
|
||||
</BoxLine>
|
||||
</BoxRow>
|
||||
<BoxRow indent={boxIndent}>
|
||||
<BoxLine pad={BOX_INNER_WIDTH}>{""}</BoxLine>
|
||||
</BoxRow>
|
||||
<BoxRow indent={boxIndent}>
|
||||
<Text color={TONE.brand}>{bot}</Text>
|
||||
</BoxRow>
|
||||
<Text color={FG.faint}>{"×"}</Text>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text>{"🐋"}</Text>
|
||||
<Text color={TONE.accent} bold>
|
||||
{"DeepSeek"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{" ".repeat(startIndent)}</Text>
|
||||
<Text color={FG.sub}>{startTextRaw}</Text>
|
||||
<Box marginTop={1} flexDirection="column" alignItems="center">
|
||||
<Text color={FG.body}>{tagline}</Text>
|
||||
<Text color={FG.meta}>{taglineSub}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{" ".repeat(hintsIndent)}</Text>
|
||||
{HINTS.map((cmd, i) => (
|
||||
<React.Fragment key={cmd}>
|
||||
<Text color={FG.meta}>{cmd}</Text>
|
||||
{i < HINTS.length - 1 && <Text color={FG.faint}>{" · "}</Text>}
|
||||
</React.Fragment>
|
||||
<Text color={FG.sub}>{startTextRaw}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="row" gap={3}>
|
||||
{HINTS.map((cmd) => (
|
||||
<Text key={cmd} color={FG.meta}>
|
||||
{cmd}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{dashboardUrl ? (
|
||||
<Box marginTop={1} flexDirection="row" justifyContent="center">
|
||||
<Box marginTop={1} flexDirection="row" gap={1}>
|
||||
<Text color={TONE.brand} bold>
|
||||
{"▸ web · "}
|
||||
{"▸ web"}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{"·"}</Text>
|
||||
<Text color={TONE.accent}>{dashboardUrl}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BoxRow({
|
||||
indent,
|
||||
children,
|
||||
}: {
|
||||
indent: number;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box>
|
||||
<Text>{" ".repeat(indent)}</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BoxLine({
|
||||
pad,
|
||||
children,
|
||||
}: {
|
||||
pad: number;
|
||||
children?: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
if (children === "" || children === undefined) {
|
||||
return (
|
||||
<>
|
||||
<Text color={TONE.brand}>{"║"}</Text>
|
||||
<Text>{" ".repeat(pad)}</Text>
|
||||
<Text color={TONE.brand}>{"║"}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Text color={TONE.brand}>{"║"}</Text>
|
||||
{children}
|
||||
<Text color={TONE.brand}>{"║"}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function centerInside(text: string, pad: number): string {
|
||||
if (text.length >= pad) return text.slice(0, pad);
|
||||
const left = Math.floor((pad - text.length) / 2);
|
||||
const right = pad - text.length - left;
|
||||
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
|
||||
}
|
||||
@@ -443,5 +443,8 @@ export function buildSpec(name: string, argsByName: Record<string, string>): str
|
||||
}
|
||||
|
||||
function quoteIfNeeded(s: string): string {
|
||||
return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
||||
// Escape backslashes BEFORE quotes — otherwise a trailing `\` in the
|
||||
// input would consume the closing quote when a downstream parser
|
||||
// un-escapes the output (CodeQL js/incomplete-sanitization).
|
||||
return /\s|"/.test(s) ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
|
||||
}
|
||||
@@ -1,40 +1,35 @@
|
||||
import { Text } from "ink";
|
||||
import { Box } from "ink";
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { BranchCard as BranchCardData } from "../state/cards.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
|
||||
const BAR_CELLS = 28;
|
||||
|
||||
export function BranchCard({ card }: { card: BranchCardData }): React.ReactElement {
|
||||
const ratio = card.total > 0 ? card.completed / card.total : 0;
|
||||
const filled = Math.max(0, Math.min(BAR_CELLS, Math.round(ratio * BAR_CELLS)));
|
||||
const tone = card.done ? TONE.ok : CARD.streaming.color;
|
||||
const tone = card.done ? TONE.ok : CARD.branch.color;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Card tone={tone}>
|
||||
<CardHeader
|
||||
tone="streaming"
|
||||
glyph="⎇"
|
||||
title={card.done ? "Branching done" : "Branching"}
|
||||
meta={`· ${card.completed} of ${card.total} samples`}
|
||||
barColor={tone}
|
||||
tone={tone}
|
||||
title={card.done ? "branching done" : "branching"}
|
||||
meta={[`${card.completed} of ${card.total} samples`]}
|
||||
/>
|
||||
<BarRow tone="streaming" indent={0} />
|
||||
<BarRow tone="streaming">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={tone}>{"█".repeat(filled)}</Text>
|
||||
<Text color={FG.faint}>{"░".repeat(BAR_CELLS - filled)}</Text>
|
||||
<Text color={FG.faint}>{` ${(ratio * 100).toFixed(0)}%`}</Text>
|
||||
</BarRow>
|
||||
{!card.done && card.completed > 0 && (
|
||||
<BarRow tone="streaming">
|
||||
<Text color={FG.faint}>
|
||||
{`latest: #${card.latestIndex} · T=${card.latestTemperature.toFixed(2)} · ${card.latestUncertainties} unc`}
|
||||
</Text>
|
||||
</BarRow>
|
||||
)}
|
||||
</Box>
|
||||
<Text color={FG.faint}>{`${(ratio * 100).toFixed(0)}%`}</Text>
|
||||
</Box>
|
||||
{!card.done && card.completed > 0 ? (
|
||||
<Text color={FG.faint}>
|
||||
{`latest · #${card.latestIndex} · T=${card.latestTemperature.toFixed(2)} · ${card.latestUncertainties} unc`}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Box, Spacer, Text } from "ink";
|
||||
import React from "react";
|
||||
import { CARD, type CardTone, FG } from "../theme/tokens.js";
|
||||
import { STATUS, STRUCT } from "./glyphs.js";
|
||||
|
||||
const CardToneCtx = React.createContext<CardTone>("user");
|
||||
|
||||
export interface CardProps {
|
||||
tone: CardTone;
|
||||
glyph?: string;
|
||||
title?: string;
|
||||
meta?: string;
|
||||
collapsed?: boolean;
|
||||
trailing?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
tone,
|
||||
glyph,
|
||||
title,
|
||||
meta,
|
||||
collapsed,
|
||||
trailing,
|
||||
children,
|
||||
}: CardProps): React.ReactElement {
|
||||
const t = CARD[tone];
|
||||
const headerGlyph = glyph ?? t.glyph;
|
||||
const showBar = tone !== "user";
|
||||
|
||||
return (
|
||||
<CardToneCtx.Provider value={tone}>
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text>{" "}</Text>
|
||||
{showBar ? <Text color={t.color}>{STRUCT.bar}</Text> : <Text> </Text>}
|
||||
<Text> </Text>
|
||||
<Text bold color={t.color}>
|
||||
{headerGlyph}
|
||||
</Text>
|
||||
{title !== undefined && (
|
||||
<Text bold color={tone === "user" ? FG.sub : FG.strong}>
|
||||
{" "}
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Spacer />
|
||||
{meta !== undefined && <Text color={FG.meta}>{meta}</Text>}
|
||||
{trailing !== undefined && (
|
||||
<>
|
||||
<Text> </Text>
|
||||
{trailing}
|
||||
</>
|
||||
)}
|
||||
{collapsed !== undefined && (
|
||||
<Text color={FG.faint}>
|
||||
{" "}
|
||||
{collapsed ? STATUS.collapsed : STATUS.expanded}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
</CardToneCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardLine({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
const tone = React.useContext(CardToneCtx);
|
||||
const t = CARD[tone];
|
||||
const showBar = tone !== "user";
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text>{" "}</Text>
|
||||
{showBar ? <Text color={t.color}>{STRUCT.bar}</Text> : <Text> </Text>}
|
||||
<Text>{" "}</Text>
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardSpacer(): React.ReactElement {
|
||||
const tone = React.useContext(CardToneCtx);
|
||||
const t = CARD[tone];
|
||||
const showBar = tone !== "user";
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text>{" "}</Text>
|
||||
{showBar ? <Text color={t.color}>{STRUCT.bar}</Text> : <Text> </Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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 { CARD, type CardTone, FG } from "../theme/tokens.js";
|
||||
import { STRUCT } from "./glyphs.js";
|
||||
|
||||
export interface CardHeaderProps {
|
||||
tone: CardTone;
|
||||
glyph: string;
|
||||
/** Bold + colored heading text right after the glyph. */
|
||||
title: string;
|
||||
/** Dimmed continuation appended after the title (e.g. " src/cli/ui/App.tsx"). */
|
||||
subtitle?: string;
|
||||
/** Right-aligned dim meta (e.g. "0.08s · 1224 lines"). */
|
||||
meta?: string;
|
||||
/** Right-aligned colored badge slot, painted after `meta` (e.g. running/done badge). */
|
||||
trailing?: React.ReactNode;
|
||||
/** Inline node painted between the title and the subtitle — for spinners etc. */
|
||||
inline?: React.ReactNode;
|
||||
/** Override the bar color (default = card tone color). */
|
||||
barColor?: string;
|
||||
/** Override the title color (default = strong fg). User card uses sub fg. */
|
||||
titleColor?: string;
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
tone,
|
||||
glyph,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
trailing,
|
||||
inline,
|
||||
barColor,
|
||||
titleColor,
|
||||
}: CardHeaderProps): React.ReactElement {
|
||||
const t = CARD[tone];
|
||||
const bar = barColor ?? t.color;
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text>{" "}</Text>
|
||||
<Text color={bar}>{STRUCT.bar}</Text>
|
||||
<Text> </Text>
|
||||
<Text bold color={t.color}>
|
||||
{glyph}
|
||||
</Text>
|
||||
<Text bold color={titleColor ?? FG.strong}>
|
||||
{` ${title}`}
|
||||
</Text>
|
||||
{inline && (
|
||||
<>
|
||||
<Text> </Text>
|
||||
{inline}
|
||||
</>
|
||||
)}
|
||||
{subtitle && <Text color={FG.sub}>{` ${subtitle}`}</Text>}
|
||||
<Box flexGrow={1} />
|
||||
{meta && <Text color={FG.faint}>{meta}</Text>}
|
||||
{trailing && (
|
||||
<>
|
||||
<Text> </Text>
|
||||
{trailing}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -22,11 +22,9 @@ import { UserCard } from "./UserCard.js";
|
||||
import { WarnCard } from "./WarnCard.js";
|
||||
|
||||
export function CardRenderer({ card }: { card: Card }): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{renderCard(card)}
|
||||
</Box>
|
||||
);
|
||||
// No marginTop here — Card primitive owns inter-card spacing. Doubling it
|
||||
// bakes 2 blank rows into every static-emitted block of bytes.
|
||||
return <Box flexDirection="column">{renderCard(card)}</Box>;
|
||||
}
|
||||
|
||||
function renderCard(card: Card): React.ReactElement {
|
||||
@@ -57,8 +55,6 @@ function renderCard(card: Card): React.ReactElement {
|
||||
return <SubAgentCard card={card} />;
|
||||
case "search":
|
||||
return <SearchCard card={card} />;
|
||||
case "approval":
|
||||
return <FallbackCard card={card} />;
|
||||
case "live":
|
||||
return <LiveCard card={card} />;
|
||||
case "ctx":
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { CtxCard as CtxCardData } from "../state/cards.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
const BAR_CELLS = 32;
|
||||
|
||||
function row(label: string, tokens: number, ratio: number, color: string): React.ReactElement {
|
||||
const filled = Math.max(0, Math.min(BAR_CELLS, Math.round(ratio * BAR_CELLS)));
|
||||
return (
|
||||
<BarRow tone="usage">
|
||||
<Text color={FG.sub}>{label.padEnd(8)}</Text>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.sub}>{label.padEnd(7)}</Text>
|
||||
<Text color={color}>{"█".repeat(filled)}</Text>
|
||||
<Text color={FG.faint}>{"░".repeat(BAR_CELLS - filled)}</Text>
|
||||
<Text bold color={FG.body}>{` ${tokens.toLocaleString()}`}</Text>
|
||||
<Text color={FG.faint}>{` ${(ratio * 100).toFixed(1)}%`}</Text>
|
||||
</BarRow>
|
||||
<Text bold color={FG.body}>
|
||||
{tokens.toLocaleString()}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{`· ${(ratio * 100).toFixed(1)}%`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,37 +29,30 @@ export function CtxCard({ card }: { card: CtxCardData }): React.ReactElement {
|
||||
const usedPct = (used / cap) * 100;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text>{" "}</Text>
|
||||
<Text bold color={CARD.usage.color}>
|
||||
{"⌘ Context window"}
|
||||
</Text>
|
||||
<Text color={FG.faint}>
|
||||
{` · ${used.toLocaleString()} / ${cap.toLocaleString()} (${usedPct.toFixed(1)}%)`}
|
||||
</Text>
|
||||
</Box>
|
||||
<BarRow tone="usage" indent={0} />
|
||||
<Card tone={TONE.brand}>
|
||||
<CardHeader
|
||||
glyph="⌘"
|
||||
tone={TONE.brand}
|
||||
title="context"
|
||||
meta={[`${used.toLocaleString()} / ${cap.toLocaleString()} (${usedPct.toFixed(1)}%)`]}
|
||||
/>
|
||||
{row("system", card.systemTokens, card.systemTokens / cap, TONE.brand)}
|
||||
{row("tools", card.toolsTokens, card.toolsTokens / cap, TONE.warn)}
|
||||
{row("log", card.logTokens, card.logTokens / cap, TONE.ok)}
|
||||
{row("input", card.inputTokens, card.inputTokens / cap, TONE.accent)}
|
||||
{card.topTools.length > 0 && (
|
||||
{card.topTools.length > 0 ? (
|
||||
<>
|
||||
<BarRow tone="usage" indent={0} />
|
||||
<BarRow tone="usage">
|
||||
<Text color={FG.faint}>
|
||||
{`top tools (${card.toolsCount} total · ${card.logMessages} log msgs):`}
|
||||
</Text>
|
||||
</BarRow>
|
||||
<Text color={FG.faint}>
|
||||
{`top tools · ${card.toolsCount} total · ${card.logMessages} log msgs`}
|
||||
</Text>
|
||||
{card.topTools.slice(0, 5).map((t) => (
|
||||
<BarRow key={`${t.turn}-${t.name}`} tone="usage">
|
||||
<Text color={FG.sub}>{` ${t.name}`}</Text>
|
||||
<Text color={FG.faint}>{` · turn ${t.turn} · ${t.tokens.toLocaleString()}`}</Text>
|
||||
</BarRow>
|
||||
<Box key={`${t.turn}-${t.name}`} flexDirection="row" gap={1}>
|
||||
<Text color={FG.sub}>{t.name}</Text>
|
||||
<Text color={FG.faint}>{`· turn ${t.turn} · ${t.tokens.toLocaleString()}`}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { DiffCard as DiffCardData } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
|
||||
const LINE_COLOR = {
|
||||
ctx: FG.sub,
|
||||
@@ -13,47 +13,53 @@ const LINE_COLOR = {
|
||||
fold: FG.faint,
|
||||
} as const;
|
||||
|
||||
const LINE_GLYPH = {
|
||||
ctx: " ",
|
||||
add: "+",
|
||||
del: "-",
|
||||
fold: "⋮",
|
||||
} as const;
|
||||
|
||||
export function DiffCard({ card }: { card: DiffCardData }): React.ReactElement {
|
||||
const meta = (
|
||||
<>
|
||||
<Text color={TONE.ok}>{`+${card.stats.add}`}</Text>
|
||||
<Text color={FG.faint}>{" / "}</Text>
|
||||
<Text color={TONE.err}>{`-${card.stats.del}`}</Text>
|
||||
</>
|
||||
);
|
||||
const showFooter = card.hunks.length > 0;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<CardHeader tone="diff" glyph="±" title="Edit" subtitle={card.file} trailing={meta} />
|
||||
<Card tone={TONE.ok}>
|
||||
<CardHeader
|
||||
glyph="±"
|
||||
tone={TONE.ok}
|
||||
title={card.file}
|
||||
titleColor={FG.body}
|
||||
meta={[
|
||||
{ text: `+${card.stats.add}`, color: TONE.ok },
|
||||
{ text: `-${card.stats.del}`, color: TONE.err },
|
||||
]}
|
||||
/>
|
||||
{card.hunks.map((hunk) => (
|
||||
<Box key={`${card.id}:${hunk.header}`} flexDirection="column">
|
||||
<BarRow tone="diff" indent={0} />
|
||||
<BarRow tone="diff">
|
||||
<Text italic color={FG.faint}>
|
||||
{hunk.header}
|
||||
</Text>
|
||||
</BarRow>
|
||||
<Text italic color={FG.faint}>
|
||||
{hunk.header}
|
||||
</Text>
|
||||
{hunk.lines.map((line, li) => (
|
||||
<BarRow key={`${card.id}:${hunk.header}:${li}`} tone="diff">
|
||||
<Text color={LINE_COLOR[line.kind]}>{line.text}</Text>
|
||||
</BarRow>
|
||||
<Box key={`${card.id}:${hunk.header}:${li}`} flexDirection="row" gap={1}>
|
||||
<Text color={LINE_COLOR[line.kind]}>{LINE_GLYPH[line.kind]}</Text>
|
||||
<Text color={LINE_COLOR[line.kind]} dimColor={line.kind === "ctx"}>
|
||||
{line.text}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{showFooter && (
|
||||
<>
|
||||
<BarRow tone="diff" indent={0} />
|
||||
<BarRow tone="diff">
|
||||
<Text bold color={TONE.ok}>
|
||||
{"[a] apply"}
|
||||
</Text>
|
||||
<Text color={FG.sub}>{" [s] skip "}</Text>
|
||||
<Text bold color={TONE.err}>
|
||||
{"[r] reject"}
|
||||
</Text>
|
||||
</BarRow>
|
||||
</>
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<Text bold color={TONE.ok}>
|
||||
[a] apply
|
||||
</Text>
|
||||
<Text color={FG.sub}>[s] skip</Text>
|
||||
<Text bold color={TONE.err}>
|
||||
[r] reject
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { DoctorCard as DoctorCardData, DoctorCheckEntry } from "../state/cards.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
|
||||
const LEVEL_COLOR: Record<DoctorCheckEntry["level"], string> = {
|
||||
ok: TONE.ok,
|
||||
@@ -15,7 +15,7 @@ const LEVEL_COLOR: Record<DoctorCheckEntry["level"], string> = {
|
||||
const LEVEL_GLYPH: Record<DoctorCheckEntry["level"], string> = {
|
||||
ok: "✓",
|
||||
warn: "⚠",
|
||||
fail: "✗",
|
||||
fail: "✖",
|
||||
};
|
||||
|
||||
const LEVEL_TAG: Record<DoctorCheckEntry["level"], string> = {
|
||||
@@ -28,21 +28,22 @@ export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactEleme
|
||||
const ok = card.checks.filter((c) => c.level === "ok").length;
|
||||
const warn = card.checks.filter((c) => c.level === "warn").length;
|
||||
const fail = card.checks.filter((c) => c.level === "fail").length;
|
||||
const summary = `${card.checks.length} checks · ${ok} passed${warn > 0 ? ` · ${warn} warn` : ""}${fail > 0 ? ` · ${fail} fail` : ""}`;
|
||||
const labelWidth = card.checks.reduce((m, c) => Math.max(m, c.label.length), 0);
|
||||
const summary = `${card.checks.length} checks · ${ok} passed${warn > 0 ? ` · ${warn} warn` : ""}${fail > 0 ? ` · ${fail} fail` : ""}`;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<CardHeader tone="tool" glyph="⚕" title="Doctor" meta={summary} barColor={CARD.tool.color} />
|
||||
<BarRow tone="tool" indent={0} />
|
||||
<Card tone={CARD.tool.color}>
|
||||
<CardHeader glyph="⚕" tone={CARD.tool.color} title="doctor" meta={[summary]} />
|
||||
{card.checks.map((c) => (
|
||||
<BarRow key={c.label} tone="tool">
|
||||
<Box key={c.label} flexDirection="row" gap={1}>
|
||||
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_GLYPH[c.level]}</Text>
|
||||
<Text bold color={FG.body}>{` ${c.label.padEnd(labelWidth + 1)}`}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{c.label.padEnd(labelWidth + 1)}
|
||||
</Text>
|
||||
<Text color={FG.sub}>{c.detail}</Text>
|
||||
<Text color={LEVEL_COLOR[c.level]}>{` ${LEVEL_TAG[c.level]}`}</Text>
|
||||
</BarRow>
|
||||
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_TAG[c.level]}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +1,53 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { ErrorCard as ErrorCardData } from "../state/cards.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
const STACK_TAIL = 5;
|
||||
|
||||
export function ErrorCard({ card }: { card: ErrorCardData }): React.ReactElement {
|
||||
const meta =
|
||||
const retryNote =
|
||||
card.retries !== undefined && card.retries > 0
|
||||
? `· ${card.retries} retr${card.retries === 1 ? "y" : "ies"}`
|
||||
: undefined;
|
||||
? `${card.retries} retr${card.retries === 1 ? "y" : "ies"}`
|
||||
: null;
|
||||
const stackLines = card.stack ? card.stack.split("\n") : [];
|
||||
const stackTrunc = stackLines.length > STACK_TAIL;
|
||||
const stackVisible = stackTrunc ? stackLines.slice(-STACK_TAIL) : stackLines;
|
||||
const stackHidden = stackTrunc ? stackLines.length - stackVisible.length : 0;
|
||||
const hasStack = stackVisible.length > 0;
|
||||
const messageLines = card.message.split("\n");
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<CardHeader tone="error" glyph="✖" title="Error" subtitle={card.title} meta={meta} />
|
||||
<BarRow tone="error" indent={0} />
|
||||
{card.message.split("\n").map((line, i) => (
|
||||
<BarRow key={`${card.id}:msg:${i}`} tone="error">
|
||||
<Text color={CARD.error.color}>{line}</Text>
|
||||
</BarRow>
|
||||
))}
|
||||
{hasStack && (
|
||||
<>
|
||||
<BarRow tone="error" indent={0} />
|
||||
<BarRow tone="error">
|
||||
<Text color={FG.meta}>{"stack trace"}</Text>
|
||||
</BarRow>
|
||||
{stackHidden > 0 && (
|
||||
<BarRow tone="error">
|
||||
<Text color={FG.faint}>
|
||||
{`⋮ ${stackHidden} earlier stack line${stackHidden === 1 ? "" : "s"} hidden`}
|
||||
</Text>
|
||||
</BarRow>
|
||||
)}
|
||||
{stackVisible.map((line, i) => (
|
||||
<BarRow key={`${card.id}:stk:${stackHidden + i}`} tone="error">
|
||||
<Text color={FG.meta}>{line}</Text>
|
||||
</BarRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<BarRow tone="error" indent={0} />
|
||||
<BarRow tone="error">
|
||||
<Text bold color={TONE.err}>
|
||||
{"[r] retry"}
|
||||
<Card tone={TONE.err}>
|
||||
<CardHeader
|
||||
glyph="✖"
|
||||
tone={TONE.err}
|
||||
title={card.title || "error"}
|
||||
meta={retryNote ? [retryNote] : undefined}
|
||||
/>
|
||||
{messageLines.map((line, i) => (
|
||||
<Text key={`${card.id}:msg:${i}`} color={TONE.err}>
|
||||
{line || " "}
|
||||
</Text>
|
||||
<Text color={FG.sub}>{" [s] skip"}</Text>
|
||||
</BarRow>
|
||||
</Box>
|
||||
))}
|
||||
{hasStack ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={FG.meta}>stack trace</Text>
|
||||
{stackHidden > 0 ? (
|
||||
<Text color={FG.faint}>
|
||||
{`⋮ ${stackHidden} earlier stack line${stackHidden === 1 ? "" : "s"} hidden`}
|
||||
</Text>
|
||||
) : null}
|
||||
{stackVisible.map((line, i) => (
|
||||
<Text key={`${card.id}:stk:${stackHidden + i}`} color={FG.meta}>
|
||||
{line || " "}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,7 @@ export function LiveCard({ card }: { card: LiveCardData }): React.ReactElement {
|
||||
const color = TONE_TO_COLOR[card.tone];
|
||||
const glyph = VARIANT_GLYPH[card.variant];
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text>{" "}</Text>
|
||||
<Box paddingLeft={2} flexDirection="row" gap={1}>
|
||||
{card.variant === "thinking" ? (
|
||||
<Spinner kind="circle" color={color} bold />
|
||||
) : (
|
||||
@@ -40,8 +39,8 @@ export function LiveCard({ card }: { card: LiveCardData }): React.ReactElement {
|
||||
{glyph}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={FG.body}>{` ${card.text}`}</Text>
|
||||
{card.meta !== undefined && <Text color={FG.faint}>{` ${card.meta}`}</Text>}
|
||||
<Text color={FG.body}>{card.text}</Text>
|
||||
{card.meta !== undefined ? <Text color={FG.faint}>{`· ${card.meta}`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { MemoryCard as MemoryCardData, MemoryEntry } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
|
||||
const CATEGORY_ORDER: ReadonlyArray<MemoryEntry["category"]> = [
|
||||
"user",
|
||||
@@ -14,10 +14,10 @@ const CATEGORY_ORDER: ReadonlyArray<MemoryEntry["category"]> = [
|
||||
];
|
||||
|
||||
const CATEGORY_LABEL: Record<MemoryEntry["category"], string> = {
|
||||
user: "USER",
|
||||
feedback: "FEEDBACK",
|
||||
project: "PROJECT",
|
||||
reference: "REFERENCE",
|
||||
user: "user",
|
||||
feedback: "feedback",
|
||||
project: "project",
|
||||
reference: "reference",
|
||||
};
|
||||
|
||||
const CATEGORY_GLYPH: Record<MemoryEntry["category"], string> = {
|
||||
@@ -42,14 +42,13 @@ export function MemoryCard({ card }: { card: MemoryCardData }): React.ReactEleme
|
||||
const tokens =
|
||||
card.tokens > 1024 ? `~${(card.tokens / 1024).toFixed(1)}K tok` : `~${card.tokens} tok`;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Card tone={FG.meta}>
|
||||
<CardHeader
|
||||
tone="memory"
|
||||
glyph="⌑"
|
||||
title="Context"
|
||||
subtitle={`· ${summary}`}
|
||||
meta={tokens}
|
||||
tone={FG.meta}
|
||||
title="context"
|
||||
titleColor={FG.sub}
|
||||
meta={summary ? [summary, tokens] : [tokens]}
|
||||
/>
|
||||
{CATEGORY_ORDER.filter((c) => counts[c] > 0).map((category) => {
|
||||
const all = card.entries.filter((e) => e.category === category);
|
||||
@@ -57,27 +56,18 @@ export function MemoryCard({ card }: { card: MemoryCardData }): React.ReactEleme
|
||||
const remaining = all.length - shown.length;
|
||||
return (
|
||||
<Box key={category} flexDirection="column">
|
||||
<BarRow tone="memory" indent={0} />
|
||||
<BarRow tone="memory">
|
||||
<Text color={FG.faint}>
|
||||
{CATEGORY_LABEL[category]} ({counts[category]})
|
||||
</Text>
|
||||
</BarRow>
|
||||
<Text color={FG.faint}>{`${CATEGORY_LABEL[category]} (${counts[category]})`}</Text>
|
||||
{shown.map((entry) => (
|
||||
<BarRow key={`${category}:${entry.summary}`} tone="memory">
|
||||
<Text color={CATEGORY_GLYPH_COLOR[category]}>{CATEGORY_GLYPH[category]} </Text>
|
||||
<Box key={`${category}:${entry.summary}`} flexDirection="row" gap={1}>
|
||||
<Text color={CATEGORY_GLYPH_COLOR[category]}>{CATEGORY_GLYPH[category]}</Text>
|
||||
<Text color={FG.sub}>{entry.summary}</Text>
|
||||
</BarRow>
|
||||
</Box>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<BarRow tone="memory">
|
||||
<Text color={FG.faint}>{`⋮ +${remaining} more`}</Text>
|
||||
</BarRow>
|
||||
)}
|
||||
{remaining > 0 ? <Text color={FG.faint}>{`⋮ +${remaining} more`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { PlanCard as PlanCardData, PlanStep } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
const STATUS_GLYPH: Record<PlanStep["status"], string> = {
|
||||
queued: " ",
|
||||
queued: "○",
|
||||
running: "▶",
|
||||
done: "✓",
|
||||
failed: "✗",
|
||||
@@ -17,47 +17,90 @@ const STATUS_GLYPH: Record<PlanStep["status"], string> = {
|
||||
|
||||
const STATUS_COLOR: Record<PlanStep["status"], string> = {
|
||||
queued: FG.faint,
|
||||
running: TONE.brand,
|
||||
running: TONE_ACTIVE.brand,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
blocked: TONE.warn,
|
||||
skipped: FG.faint,
|
||||
};
|
||||
|
||||
const ACTIVE_PLAN_FOOTER = "[r] revise [s] skip step [↵] expand item";
|
||||
const VISIBLE_WINDOW = 5;
|
||||
|
||||
export function PlanCard({ card }: { card: PlanCardData }): React.ReactElement {
|
||||
const doneCount = card.steps.filter((s) => s.status === "done").length;
|
||||
const variantTag =
|
||||
card.variant === "resumed" ? " · resumed" : card.variant === "replay" ? " · ⏪ archive" : "";
|
||||
const meta = `${variantTag} · ${doneCount} of ${card.steps.length} done`;
|
||||
const showFooter = card.variant === "active";
|
||||
card.variant === "resumed" ? "resumed · " : card.variant === "replay" ? "⏪ archive · " : "";
|
||||
const progress = `${variantTag}${doneCount}/${card.steps.length} done`;
|
||||
const hasRunning = card.steps.some((s) => s.status === "running");
|
||||
const tone = hasRunning ? TONE_ACTIVE.accent : TONE.accent;
|
||||
|
||||
const window = pickWindow(card.steps);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<CardHeader tone="plan" glyph="⊞" title={card.title} meta={meta} />
|
||||
<BarRow tone="plan" indent={0} />
|
||||
{card.steps.map((step, i) => {
|
||||
<Card tone={tone}>
|
||||
<CardHeader glyph="⊞" tone={tone} title={card.title} meta={[progress]} />
|
||||
{window.hiddenBefore > 0 ? (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.ok}>✓</Text>
|
||||
<Text color={FG.faint}>{`⋯ ${window.hiddenBefore} done`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{window.steps.map((step) => {
|
||||
const isActive = step.status === "running";
|
||||
const titleColor = isActive ? FG.strong : step.status === "queued" ? FG.sub : FG.sub;
|
||||
const titleColor = isActive ? FG.strong : FG.sub;
|
||||
return (
|
||||
<BarRow key={step.id} tone="plan">
|
||||
<Text color={STATUS_COLOR[step.status]}>{`[${STATUS_GLYPH[step.status]}]`}</Text>
|
||||
<Box key={step.id} flexDirection="row" gap={1}>
|
||||
<Text color={STATUS_COLOR[step.status]}>{STATUS_GLYPH[step.status]}</Text>
|
||||
<Text bold={isActive} color={titleColor}>
|
||||
{` ${i + 1}. ${step.title}`}
|
||||
{`${step.indexLabel}. ${step.title}`}
|
||||
</Text>
|
||||
{isActive && <Text color={TONE.brand}>{" ← in progress"}</Text>}
|
||||
</BarRow>
|
||||
{isActive ? <Text color={TONE_ACTIVE.brand}>← in progress</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{showFooter && (
|
||||
<>
|
||||
<BarRow tone="plan" indent={0} />
|
||||
<BarRow tone="plan">
|
||||
<Text color={FG.meta}>{ACTIVE_PLAN_FOOTER}</Text>
|
||||
</BarRow>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{window.hiddenAfter > 0 ? (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.faint}>○</Text>
|
||||
<Text color={FG.faint}>{`⋯ ${window.hiddenAfter} upcoming`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface WindowedStep extends PlanStep {
|
||||
indexLabel: number;
|
||||
}
|
||||
|
||||
interface StepWindow {
|
||||
steps: WindowedStep[];
|
||||
hiddenBefore: number;
|
||||
hiddenAfter: number;
|
||||
}
|
||||
|
||||
/** Fixed window keeps the live strip's height constant — variable-height plan cards in the live region cause Yoga to thrash on every step transition. */
|
||||
function pickWindow(steps: ReadonlyArray<PlanStep>): StepWindow {
|
||||
if (steps.length <= VISIBLE_WINDOW) {
|
||||
return {
|
||||
steps: steps.map((s, i) => ({ ...s, indexLabel: i + 1 })),
|
||||
hiddenBefore: 0,
|
||||
hiddenAfter: 0,
|
||||
};
|
||||
}
|
||||
const anchor = anchorIndex(steps);
|
||||
const start = Math.max(0, Math.min(anchor, steps.length - VISIBLE_WINDOW));
|
||||
const end = start + VISIBLE_WINDOW;
|
||||
return {
|
||||
steps: steps.slice(start, end).map((s, i) => ({ ...s, indexLabel: start + i + 1 })),
|
||||
hiddenBefore: start,
|
||||
hiddenAfter: Math.max(0, steps.length - end),
|
||||
};
|
||||
}
|
||||
|
||||
function anchorIndex(steps: ReadonlyArray<PlanStep>): number {
|
||||
const runningIdx = steps.findIndex((s) => s.status === "running");
|
||||
if (runningIdx >= 0) return runningIdx;
|
||||
const firstPending = steps.findIndex((s) => s.status === "queued" || s.status === "blocked");
|
||||
if (firstPending >= 0) return firstPending;
|
||||
return Math.max(0, steps.length - VISIBLE_WINDOW);
|
||||
}
|
||||
@@ -2,19 +2,18 @@ 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 { clipToCells, wrapToCells } from "../../../frame/width.js";
|
||||
import { CursorBlock } from "../primitives/BarRow.js";
|
||||
import { CardBox } from "../primitives/CardBox.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader, type MetaItem } from "../primitives/CardHeader.js";
|
||||
import { CursorBlock } from "../primitives/CursorBlock.js";
|
||||
import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pill.js";
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import type { ReasoningCard as ReasoningCardData } from "../state/cards.js";
|
||||
import { CARD, FG } from "../theme/tokens.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
/** Streaming live region stays at this many rows so Ink's eraseLines can't miscount enough to flicker. Full body lands in scrollback once the card settles. */
|
||||
/** Streaming preview tail length — wide enough to feel responsive, small enough not to thrash on every chunk. Full body lives in the events log. */
|
||||
const STREAMING_PREVIEW_LINES = 4;
|
||||
/** Settled reasoning collapses to tail-only — once the model is done thinking, the conclusion is the actionable signal; the rest is in the events log via `/reasoning last`. */
|
||||
/** Once settled, only the conclusion is actionable; the rest is in `/reasoning last`. */
|
||||
const SETTLED_TAIL_LINES = 2;
|
||||
const HEADER_PAD = 1;
|
||||
const BODY_PAD = 4;
|
||||
|
||||
export function ReasoningCard({
|
||||
card,
|
||||
@@ -25,60 +24,57 @@ export function ReasoningCard({
|
||||
}): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const lineCells = Math.max(20, cols - BODY_PAD - 4);
|
||||
const lineCells = Math.max(20, cols - 4);
|
||||
|
||||
const allLines = card.text.length > 0 ? card.text.split("\n") : [];
|
||||
const showBody = expanded && (allLines.length > 0 || card.streaming);
|
||||
const barColor = card.streaming ? CARD.reasoning.color : FG.faint;
|
||||
const tone = card.aborted ? TONE.err : card.streaming ? TONE_ACTIVE.accent : TONE.accent;
|
||||
|
||||
return (
|
||||
<CardBox color={barColor}>
|
||||
<Card tone={tone}>
|
||||
<ReasoningHeader card={card} />
|
||||
{showBody && (
|
||||
<>
|
||||
<Box height={1} />
|
||||
{card.streaming ? (
|
||||
<StreamingPreview card={card} allLines={allLines} lineCells={lineCells} />
|
||||
) : (
|
||||
<SettledPreview card={card} allLines={allLines} lineCells={lineCells} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardBox>
|
||||
{showBody &&
|
||||
(card.streaming ? (
|
||||
<StreamingPreview card={card} allLines={allLines} lineCells={lineCells} />
|
||||
) : (
|
||||
<SettledPreview card={card} allLines={allLines} lineCells={lineCells} />
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningHeader({ card }: { card: ReasoningCardData }): React.ReactElement {
|
||||
const badge = modelBadgeFor(card.model);
|
||||
const mdl = PILL_MODEL[badge.kind];
|
||||
const sec = PILL_SECTION.reason;
|
||||
const meta = headerMeta(card);
|
||||
const streamingActive = card.streaming && !card.aborted;
|
||||
const headColor = card.aborted ? TONE.err : streamingActive ? TONE_ACTIVE.accent : TONE.accent;
|
||||
const glyph = streamingActive ? "◇" : "◆";
|
||||
const title = streamingActive ? "reasoning…" : card.aborted ? "reasoning (aborted)" : "reasoning";
|
||||
const meta: MetaItem[] = [];
|
||||
const m = headerMeta(card);
|
||||
if (m) meta.push(m);
|
||||
const duration = headerDuration(card);
|
||||
if (duration) meta.push(duration);
|
||||
const modelBadge = card.model ? modelBadgeFor(card.model) : null;
|
||||
return (
|
||||
<Box paddingLeft={HEADER_PAD} flexDirection="row">
|
||||
<Pill label="REASONING" bg={sec.bg} fg={sec.fg} />
|
||||
<Text>{" "}</Text>
|
||||
<Pill label={badge.label} bg={mdl.bg} fg={mdl.fg} />
|
||||
{meta && (
|
||||
<CardHeader
|
||||
glyph={glyph}
|
||||
tone={headColor}
|
||||
title={title}
|
||||
titleColor={PILL_SECTION.reason.fg}
|
||||
titleBg={PILL_SECTION.reason.bg}
|
||||
meta={meta.length > 0 ? meta : undefined}
|
||||
right={
|
||||
<>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={FG.faint}>{meta}</Text>
|
||||
{streamingActive ? <Spinner kind="braille" color={TONE_ACTIVE.accent} /> : null}
|
||||
{modelBadge ? (
|
||||
<Pill label={modelBadge.label} {...PILL_MODEL[modelBadge.kind]} bold={false} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Box flexGrow={1} />
|
||||
{card.streaming && !card.aborted && (
|
||||
<>
|
||||
<Spinner kind="braille" color={CARD.reasoning.color} />
|
||||
<Text color={CARD.reasoning.color}>{" thinking…"}</Text>
|
||||
</>
|
||||
)}
|
||||
{duration && <Text color={FG.faint}>{duration}</Text>}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function headerMeta(card: ReasoningCardData): string {
|
||||
if (card.aborted) return "aborted";
|
||||
if (card.streaming) {
|
||||
return card.tokens > 0 ? `${card.tokens.toLocaleString()} tok` : "";
|
||||
}
|
||||
@@ -112,7 +108,7 @@ function SettledPreview({ card, allLines, lineCells }: BodyProps): React.ReactEl
|
||||
const droppedLines = Math.max(0, visualLines.length - visible.length);
|
||||
return (
|
||||
<>
|
||||
{droppedLines > 0 && <ElisionHint droppedLines={droppedLines} card={card} />}
|
||||
{droppedLines > 0 ? <ElisionHint droppedLines={droppedLines} card={card} /> : null}
|
||||
<BodyLines card={card} lines={visible} lineCells={lineCells} indexOffset={droppedLines} />
|
||||
</>
|
||||
);
|
||||
@@ -138,7 +134,7 @@ function BodyLines({
|
||||
{lines.map((line, i) => {
|
||||
const isLast = i === lines.length - 1;
|
||||
return (
|
||||
<Box key={`${card.id}:b:${indexOffset + i}`} paddingLeft={BODY_PAD} flexDirection="row">
|
||||
<Box key={`${card.id}:b:${indexOffset + i}`} flexDirection="row">
|
||||
<Text italic color={FG.meta}>
|
||||
{clipToCells(line, lineCells)}
|
||||
</Text>
|
||||
@@ -164,9 +160,5 @@ function ElisionHint({
|
||||
parts.push(`${droppedLines} line${droppedLines === 1 ? "" : "s"}`);
|
||||
}
|
||||
if (card.tokens > 0) parts.push(`${card.tokens.toLocaleString()} tok`);
|
||||
return (
|
||||
<Box paddingLeft={BODY_PAD}>
|
||||
<Text color={FG.faint}>{`⋯ ${parts.join(" · ")} above · /reasoning last`}</Text>
|
||||
</Box>
|
||||
);
|
||||
return <Text color={FG.faint}>{`⋯ ${parts.join(" · ")} above · /reasoning last`}</Text>;
|
||||
}
|
||||
@@ -1,46 +1,46 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { SearchCard as SearchCardData, SearchHit } from "../state/cards.js";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
export function SearchCard({ card }: { card: SearchCardData }): React.ReactElement {
|
||||
const fileCount = new Set(card.hits.map((h) => h.file)).size;
|
||||
const meta = `${card.hits.length} hit${card.hits.length === 1 ? "" : "s"} in ${fileCount} file${
|
||||
const elapsed = `${(card.elapsedMs / 1000).toFixed(2)}s`;
|
||||
const stats = `${card.hits.length} hit${card.hits.length === 1 ? "" : "s"} · ${fileCount} file${
|
||||
fileCount === 1 ? "" : "s"
|
||||
} · ${(card.elapsedMs / 1000).toFixed(2)}s`;
|
||||
}`;
|
||||
|
||||
const grouped = groupByFile(card.hits.slice(0, 10));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<CardHeader tone="search" glyph="⊙" title="Search" subtitle={`"${card.query}"`} meta={meta} />
|
||||
{card.hits.length > 0 && (
|
||||
<>
|
||||
<BarRow tone="search" indent={0} />
|
||||
{groupByFile(card.hits.slice(0, 10)).map(([file, hits]) => (
|
||||
<Box key={file} flexDirection="column">
|
||||
<BarRow tone="search">
|
||||
<Text bold color={FG.strong}>
|
||||
{file}
|
||||
</Text>
|
||||
</BarRow>
|
||||
{hits.map((h, i) => (
|
||||
<BarRow key={`${file}:${h.line}:${i}`} tone="search">
|
||||
<Text color={FG.faint}>{`${h.line.toString().padStart(4)} │ `}</Text>
|
||||
<HighlightedLine text={h.preview} start={h.matchStart} end={h.matchEnd} />
|
||||
</BarRow>
|
||||
))}
|
||||
<Card tone={TONE.info}>
|
||||
<CardHeader
|
||||
glyph="⊙"
|
||||
tone={TONE.info}
|
||||
title="search"
|
||||
subtitle={`"${card.query}"`}
|
||||
meta={[stats, elapsed]}
|
||||
/>
|
||||
{grouped.map(([file, hits]) => (
|
||||
<Box key={file} flexDirection="column">
|
||||
<Text bold color={FG.strong}>
|
||||
{file}
|
||||
</Text>
|
||||
{hits.map((h, i) => (
|
||||
<Box key={`${file}:${h.line}:${i}`} flexDirection="row" gap={1}>
|
||||
<Text color={FG.faint}>{`${h.line.toString().padStart(4)} │`}</Text>
|
||||
<HighlightedLine text={h.preview} start={h.matchStart} end={h.matchEnd} />
|
||||
</Box>
|
||||
))}
|
||||
{card.hits.length > 10 && (
|
||||
<BarRow tone="search">
|
||||
<Text color={FG.faint}>{`⋮ +${card.hits.length - 10} more hits`}</Text>
|
||||
</BarRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
{card.hits.length > 10 ? (
|
||||
<Text color={FG.faint}>{`⋮ +${card.hits.length - 10} more hits`}</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,81 +4,66 @@ import React from "react";
|
||||
import { clipToCells, wrapToCells } from "../../../frame/width.js";
|
||||
import { useReserveRows } from "../layout/viewport-budget.js";
|
||||
import { Markdown } from "../markdown.js";
|
||||
import { CardBox } from "../primitives/CardBox.js";
|
||||
import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pill.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
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 { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
const HEADER_PAD = 1;
|
||||
const BODY_PAD = 4;
|
||||
/** Streaming live region stays at this many rows so Ink's eraseLines can't miscount enough to flicker. Full body lands in scrollback once the card settles. */
|
||||
/** Streaming preview tail length — bounded live region so chunks don't thrash whole-card layout. */
|
||||
const STREAMING_PREVIEW_LINES = 4;
|
||||
|
||||
export function StreamingCard({ card }: { card: StreamingCardData }): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
// Claim before the done-state branch — hook order must stay stable across the done flip.
|
||||
useReserveRows("stream", {
|
||||
min: STREAMING_PREVIEW_LINES + 1,
|
||||
max: STREAMING_PREVIEW_LINES + 2,
|
||||
});
|
||||
|
||||
const modelBadge = card.model ? modelBadgeFor(card.model) : null;
|
||||
const modelPill = modelBadge ? (
|
||||
<Pill label={modelBadge.label} {...PILL_MODEL[modelBadge.kind]} bold={false} />
|
||||
) : null;
|
||||
|
||||
if (card.done && !card.aborted) {
|
||||
return (
|
||||
<CardBox color={FG.faint}>
|
||||
<Box paddingLeft={BODY_PAD} flexDirection="column">
|
||||
<Markdown text={card.text} />
|
||||
</Box>
|
||||
</CardBox>
|
||||
<Card tone={TONE.ok}>
|
||||
<CardHeader glyph="‹" tone={TONE.ok} title="reply" right={modelPill} />
|
||||
<Markdown text={card.text} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const lineCells = Math.max(20, cols - BODY_PAD - 4);
|
||||
|
||||
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 aborted = !!card.aborted;
|
||||
const headColor = aborted ? TONE.err : TONE_ACTIVE.brand;
|
||||
const glyph = aborted ? "‹" : "◈";
|
||||
const headLabel = aborted ? "aborted" : "writing…";
|
||||
|
||||
return (
|
||||
<CardBox color={card.aborted ? FG.faint : CARD.streaming.color}>
|
||||
{!card.aborted && <StreamingHeader card={card} />}
|
||||
{card.aborted && (
|
||||
<Box paddingLeft={HEADER_PAD} flexDirection="row">
|
||||
<Text color={FG.faint} bold>
|
||||
— aborted —
|
||||
</Text>
|
||||
<Text color={TONE.warn}>{" stopped"}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Card tone={headColor}>
|
||||
<CardHeader
|
||||
glyph={glyph}
|
||||
tone={headColor}
|
||||
title={headLabel}
|
||||
right={
|
||||
<>
|
||||
{aborted ? null : <Spinner kind="braille" color={TONE_ACTIVE.brand} />}
|
||||
{modelPill}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{visible.map((line, i) => (
|
||||
<Box
|
||||
key={`${card.id}:${allLines.length - visible.length + i}`}
|
||||
paddingLeft={BODY_PAD}
|
||||
flexDirection="row"
|
||||
>
|
||||
<Text color={card.aborted ? FG.meta : FG.body}>{clipToCells(line, lineCells)}</Text>
|
||||
<Box key={`${card.id}:${allLines.length - visible.length + i}`} flexDirection="row">
|
||||
<Text color={aborted ? FG.meta : FG.body}>{clipToCells(line, lineCells)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{card.aborted && (
|
||||
<Box paddingLeft={BODY_PAD}>
|
||||
<Text color={FG.faint}>{"[truncated by esc]"}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</CardBox>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingHeader({ card }: { card: StreamingCardData }): React.ReactElement {
|
||||
const badge = modelBadgeFor(card.model);
|
||||
const mdl = PILL_MODEL[badge.kind];
|
||||
const sec = PILL_SECTION.output;
|
||||
return (
|
||||
<Box paddingLeft={HEADER_PAD} flexDirection="row">
|
||||
<Pill label="OUTPUT" bg={sec.bg} fg={sec.fg} />
|
||||
<Text>{" "}</Text>
|
||||
<Pill label={badge.label} bg={mdl.bg} fg={mdl.fg} />
|
||||
<Box flexGrow={1} />
|
||||
<Spinner kind="braille" color={CARD.streaming.color} />
|
||||
<Text color={CARD.streaming.color}>{" writing…"}</Text>
|
||||
</Box>
|
||||
{aborted ? <Text color={FG.faint}>[truncated by esc]</Text> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +1,145 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import React, { useContext } from "react";
|
||||
import { ActiveCardContext, Card as CardWrap } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import type { Card, SubAgentCard as SubAgentCardData } from "../state/cards.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
import { CARD, FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
const STATUS_COLOR: Record<SubAgentCardData["status"], string> = {
|
||||
running: TONE.violet,
|
||||
running: TONE_ACTIVE.violet,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
};
|
||||
|
||||
export function SubAgentCard({ card }: { card: SubAgentCardData }): React.ReactElement {
|
||||
const headColor = STATUS_COLOR[card.status];
|
||||
const headGlyph = card.status === "failed" ? "✖" : "⌬";
|
||||
const runningChildren = card.children.filter((c) => !isChildDone(c)).length;
|
||||
const isRunning = card.status === "running";
|
||||
const inLive = useContext(ActiveCardContext);
|
||||
const headerMeta = isRunning
|
||||
? runningChildren > 0
|
||||
? [`${runningChildren} running`]
|
||||
: ["working"]
|
||||
: [{ text: card.status, color: headColor }];
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<CardWrap tone={headColor}>
|
||||
<CardHeader
|
||||
tone="subagent"
|
||||
glyph="⌬"
|
||||
title={`Sub-agent · ${card.name}`}
|
||||
trailing={<Text color={STATUS_COLOR[card.status]}>{card.status}</Text>}
|
||||
glyph={headGlyph}
|
||||
tone={headColor}
|
||||
title="subagent"
|
||||
titleColor={TONE.violet}
|
||||
subtitle={card.task}
|
||||
meta={headerMeta}
|
||||
/>
|
||||
<BarRow tone="subagent" indent={0} />
|
||||
<BarRow tone="subagent">
|
||||
<Text color={FG.faint}>{"Task "}</Text>
|
||||
<Text color={FG.sub}>{card.task}</Text>
|
||||
</BarRow>
|
||||
{card.name ? <Text color={FG.faint}>{`agent · ${card.name}`}</Text> : null}
|
||||
{card.tools && card.tools.length > 0 && (
|
||||
<BarRow tone="subagent">
|
||||
<Text color={FG.faint}>{"Tools "}</Text>
|
||||
<Text color={FG.sub}>{card.tools.join(", ")}</Text>
|
||||
</BarRow>
|
||||
<Text color={FG.faint}>{`tools · ${card.tools.join(", ")}`}</Text>
|
||||
)}
|
||||
{card.children.length > 0 && (
|
||||
<>
|
||||
<BarRow tone="subagent" indent={0} />
|
||||
<BarRow tone="subagent">
|
||||
<Text color={FG.meta}>{"sub-agent stream"}</Text>
|
||||
</BarRow>
|
||||
{card.children.map((child) => (
|
||||
<BarRow key={child.id} tone="subagent">
|
||||
<Text color={CARD.subagent.color}>{"▎ "}</Text>
|
||||
<ChildSummary card={child} />
|
||||
</BarRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{card.children.map((child) => (
|
||||
<Box key={child.id} flexDirection="row" gap={1}>
|
||||
{inLive ? null : <Text color={TONE.violet}>▎</Text>}
|
||||
<ChildRow card={child} />
|
||||
</Box>
|
||||
))}
|
||||
</CardWrap>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildSummary({ card }: { card: Card }): React.ReactElement {
|
||||
function isChildDone(card: Card): boolean {
|
||||
switch (card.kind) {
|
||||
case "reasoning":
|
||||
return (
|
||||
<>
|
||||
<Text color={CARD.reasoning.color}>{"◆ "}</Text>
|
||||
<Text italic color={FG.meta}>
|
||||
{`Reasoning · ${card.paragraphs} paragraph${card.paragraphs === 1 ? "" : "s"}`}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
case "tool":
|
||||
return (
|
||||
<>
|
||||
<Text color={CARD.tool.color}>{"▣ "}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{card.name}
|
||||
</Text>
|
||||
{card.elapsedMs > 0 && (
|
||||
<Text color={FG.faint}>{` ${(card.elapsedMs / 1000).toFixed(2)}s`}</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "streaming":
|
||||
return (
|
||||
<>
|
||||
<Text color={CARD.streaming.color}>{"▶ "}</Text>
|
||||
<Text color={card.done ? FG.sub : TONE.brand}>
|
||||
{card.done ? "response" : "streaming response …"}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
case "diff":
|
||||
return (
|
||||
<>
|
||||
<Text color={CARD.diff.color}>{"± "}</Text>
|
||||
<Text color={FG.sub}>{card.file}</Text>
|
||||
</>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<>
|
||||
<Text color={CARD.error.color}>{"✖ "}</Text>
|
||||
<Text color={FG.sub}>{card.title}</Text>
|
||||
</>
|
||||
);
|
||||
return card.done;
|
||||
case "reasoning":
|
||||
return !card.streaming;
|
||||
default:
|
||||
return <Text color={FG.faint}>{`· ${card.kind}`}</Text>;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
interface ChildVisual {
|
||||
statusGlyph: React.ReactElement;
|
||||
kindGlyph: string;
|
||||
kindColor: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function ChildRow({ card }: { card: Card }): React.ReactElement {
|
||||
const v = childVisual(card);
|
||||
const isDone = isChildDone(card);
|
||||
return (
|
||||
<>
|
||||
{v.statusGlyph}
|
||||
<Text color={v.kindColor}>{v.kindGlyph}</Text>
|
||||
<Text dimColor={isDone} color={FG.body}>
|
||||
{v.text}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function runningGlyph(color: string): React.ReactElement {
|
||||
return <Spinner kind="circle" color={color} />;
|
||||
}
|
||||
|
||||
function doneGlyph(color: string): React.ReactElement {
|
||||
return <Text color={color}>✓</Text>;
|
||||
}
|
||||
|
||||
function failedGlyph(): React.ReactElement {
|
||||
return <Text color={TONE.err}>✖</Text>;
|
||||
}
|
||||
|
||||
function childVisual(card: Card): ChildVisual {
|
||||
switch (card.kind) {
|
||||
case "reasoning": {
|
||||
const done = !card.streaming;
|
||||
return {
|
||||
statusGlyph: done ? doneGlyph(TONE.ok) : runningGlyph(CARD.reasoning.color),
|
||||
kindGlyph: "◆",
|
||||
kindColor: CARD.reasoning.color,
|
||||
text: `reasoning · ${card.paragraphs} ¶`,
|
||||
};
|
||||
}
|
||||
case "tool": {
|
||||
const elapsed = card.elapsedMs > 0 ? ` · ${(card.elapsedMs / 1000).toFixed(2)}s` : "";
|
||||
return {
|
||||
statusGlyph: card.done ? doneGlyph(TONE.ok) : runningGlyph(CARD.tool.color),
|
||||
kindGlyph: "▣",
|
||||
kindColor: CARD.tool.color,
|
||||
text: `${card.name}${elapsed}`,
|
||||
};
|
||||
}
|
||||
case "streaming":
|
||||
return {
|
||||
statusGlyph: card.done ? doneGlyph(TONE.ok) : runningGlyph(CARD.streaming.color),
|
||||
kindGlyph: "◈",
|
||||
kindColor: CARD.streaming.color,
|
||||
text: card.done ? "response" : "writing …",
|
||||
};
|
||||
case "diff":
|
||||
return {
|
||||
statusGlyph: doneGlyph(TONE.ok),
|
||||
kindGlyph: "±",
|
||||
kindColor: CARD.diff.color,
|
||||
text: card.file,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
statusGlyph: failedGlyph(),
|
||||
kindGlyph: "✖",
|
||||
kindColor: CARD.error.color,
|
||||
text: card.title,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
statusGlyph: <Text color={FG.faint}>·</Text>,
|
||||
kindGlyph: "·",
|
||||
kindColor: FG.faint,
|
||||
text: card.kind,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { TaskCard as TaskCardData, TaskStep } from "../state/cards.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
const STEP_GLYPH: Record<TaskStep["status"], string> = {
|
||||
queued: "○",
|
||||
@@ -15,13 +15,13 @@ const STEP_GLYPH: Record<TaskStep["status"], string> = {
|
||||
|
||||
const STEP_COLOR: Record<TaskStep["status"], string> = {
|
||||
queued: FG.faint,
|
||||
running: TONE.brand,
|
||||
running: TONE.warn,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
};
|
||||
|
||||
const TASK_COLOR: Record<TaskCardData["status"], string> = {
|
||||
running: CARD.task.color,
|
||||
running: TONE.warn,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
};
|
||||
@@ -34,33 +34,28 @@ const TASK_GLYPH: Record<TaskCardData["status"], string> = {
|
||||
|
||||
export function TaskCard({ card }: { card: TaskCardData }): React.ReactElement {
|
||||
const elapsed = `${(card.elapsedMs / 1000).toFixed(1)}s`;
|
||||
const showSteps = card.steps.length > 0;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Card tone={TASK_COLOR[card.status]}>
|
||||
<CardHeader
|
||||
tone="task"
|
||||
glyph={TASK_GLYPH[card.status]}
|
||||
title={`Step ${card.index} of ${card.total} · ${card.title}`}
|
||||
meta={`${elapsed} · `}
|
||||
trailing={<Text color={TASK_COLOR[card.status]}>{card.status}</Text>}
|
||||
barColor={TASK_COLOR[card.status]}
|
||||
tone={TASK_COLOR[card.status]}
|
||||
title={`step ${card.index}/${card.total}`}
|
||||
subtitle={card.title}
|
||||
meta={[elapsed, card.status]}
|
||||
/>
|
||||
{showSteps && (
|
||||
<>
|
||||
<BarRow tone="task" indent={0} />
|
||||
{card.steps.map((step) => (
|
||||
<BarRow key={step.id} tone="task">
|
||||
<Text color={STEP_COLOR[step.status]}>{STEP_GLYPH[step.status]}</Text>
|
||||
<Text bold color={FG.body}>{` ${(step.toolName ?? "step").padEnd(7)} `}</Text>
|
||||
<Text color={FG.sub}>{step.title}</Text>
|
||||
{step.detail && <Text color={FG.faint}>{` ${step.detail}`}</Text>}
|
||||
{step.elapsedMs !== undefined && (
|
||||
<Text color={FG.faint}>{` ${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
|
||||
)}
|
||||
</BarRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{card.steps.map((step) => (
|
||||
<Box key={step.id} flexDirection="row" gap={1}>
|
||||
<Text color={STEP_COLOR[step.status]}>{STEP_GLYPH[step.status]}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{(step.toolName ?? "step").padEnd(7)}
|
||||
</Text>
|
||||
<Text color={FG.sub}>{step.title}</Text>
|
||||
{step.detail ? <Text color={FG.faint}>{step.detail}</Text> : null}
|
||||
{step.elapsedMs !== undefined ? (
|
||||
<Text color={FG.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,14 @@ 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 { clipToCells } from "../../../frame/width.js";
|
||||
import { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader, type MetaItem } from "../primitives/CardHeader.js";
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import type { ToolCard as ToolCardData } from "../state/cards.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
const READ_TAIL = 2;
|
||||
const OTHER_TAIL = 5;
|
||||
const BODY_INDENT_CELLS = 7;
|
||||
|
||||
/** Read-style tools dump file/list bodies — short tail is enough; the model already has the full text in context. */
|
||||
function tailLinesFor(name: string): number {
|
||||
@@ -24,53 +23,120 @@ function tailLinesFor(name: string): number {
|
||||
export function ToolCard({ card }: { card: ToolCardData }): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const lineCells = Math.max(20, cols - BODY_INDENT_CELLS - 1);
|
||||
const lineCells = Math.max(20, cols - 4);
|
||||
const argsLabel = formatArgsSummary(card.args);
|
||||
const meta = formatMeta(card);
|
||||
const allLines = card.output.length > 0 ? card.output.split("\n") : [];
|
||||
const tail = tailLinesFor(card.name);
|
||||
const truncated = allLines.length > tail;
|
||||
const visible = truncated ? allLines.slice(-tail) : allLines;
|
||||
const hidden = truncated ? allLines.length - visible.length : 0;
|
||||
const errColor = card.exitCode && card.exitCode !== 0 ? CARD.error.color : FG.sub;
|
||||
const showBody = visible.length > 0;
|
||||
const status = toolStatus(card);
|
||||
const headColor = headerColorFor(status);
|
||||
const errColor = card.exitCode && card.exitCode !== 0 ? TONE.err : FG.sub;
|
||||
// Rejected calls show a single trailing badge — the verbose JSON error body
|
||||
// is already conveyed by the badge, so dropping the body keeps the card tight.
|
||||
const showBody = !card.rejected && visible.length > 0;
|
||||
|
||||
const meta: MetaItem[] = [];
|
||||
if (card.retry) {
|
||||
meta.push({ text: `↻ ${card.retry.attempt}/${card.retry.max}`, color: TONE.warn });
|
||||
}
|
||||
if (card.rejected) {
|
||||
meta.push({ text: "rejected", color: TONE.err });
|
||||
}
|
||||
for (const part of metaTrail(card)) meta.push(part);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Card tone={headColor}>
|
||||
<CardHeader
|
||||
tone="tool"
|
||||
glyph="▣"
|
||||
glyph={statusGlyph(status)}
|
||||
tone={headColor}
|
||||
title={card.name}
|
||||
subtitle={argsLabel || undefined}
|
||||
meta={meta || undefined}
|
||||
inline={!card.done ? <Spinner kind="braille" color={CARD.tool.color} bold /> : undefined}
|
||||
trailing={
|
||||
card.retry ? (
|
||||
<Text color={TONE.warn} bold>{`↻ retry ${card.retry.attempt}/${card.retry.max}`}</Text>
|
||||
meta={meta.length > 0 ? meta : undefined}
|
||||
right={
|
||||
status === "running" ? (
|
||||
<Spinner kind="braille" color={TONE_ACTIVE.brand} bold />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{showBody && (
|
||||
<>
|
||||
<BarRow tone="tool" indent={0} />
|
||||
{hidden > 0 && (
|
||||
<BarRow tone="tool">
|
||||
<Text color={FG.faint}>
|
||||
{`⋮ ${hidden} earlier line${hidden === 1 ? "" : "s"} (use /tool to read full)`}
|
||||
</Text>
|
||||
</BarRow>
|
||||
)}
|
||||
{hidden > 0 ? (
|
||||
<Text color={FG.faint}>
|
||||
{`⋮ ${hidden} earlier line${hidden === 1 ? "" : "s"} (use /tool to read full)`}
|
||||
</Text>
|
||||
) : null}
|
||||
{visible.map((line, i) => (
|
||||
<BarRow key={`${card.id}:${hidden + i}`} tone="tool">
|
||||
<Text color={errColor}>{clipToCells(line, lineCells)}</Text>
|
||||
</BarRow>
|
||||
<Text
|
||||
key={`${card.id}:${hidden + i}`}
|
||||
color={errColor}
|
||||
dimColor={!card.exitCode || card.exitCode === 0}
|
||||
>
|
||||
{clipToCells(line, lineCells) || " "}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolStatus = "running" | "ok" | "rejected" | "error" | "aborted";
|
||||
|
||||
function toolStatus(card: ToolCardData): ToolStatus {
|
||||
if (card.rejected) return "rejected";
|
||||
if (card.aborted) return "aborted";
|
||||
if (!card.done) return "running";
|
||||
if (card.exitCode !== undefined && card.exitCode !== 0) return "error";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function statusGlyph(s: ToolStatus): string {
|
||||
switch (s) {
|
||||
case "running":
|
||||
return "▢";
|
||||
case "ok":
|
||||
return "✓";
|
||||
case "rejected":
|
||||
return "✗";
|
||||
case "error":
|
||||
return "✖";
|
||||
case "aborted":
|
||||
return "⊘";
|
||||
}
|
||||
}
|
||||
|
||||
function headerColorFor(s: ToolStatus): string {
|
||||
switch (s) {
|
||||
case "ok":
|
||||
return TONE.ok;
|
||||
case "rejected":
|
||||
case "error":
|
||||
case "aborted":
|
||||
return TONE.err;
|
||||
case "running":
|
||||
return TONE_ACTIVE.brand;
|
||||
}
|
||||
}
|
||||
|
||||
function metaTrail(card: ToolCardData): string[] {
|
||||
const parts: string[] = [];
|
||||
const inputBytes = largestStringInputBytes(card.args);
|
||||
if (inputBytes !== null) parts.push(`${formatBytes(inputBytes)} in`);
|
||||
if (card.elapsedMs > 0) parts.push(`${(card.elapsedMs / 1000).toFixed(2)}s`);
|
||||
if (
|
||||
card.done &&
|
||||
!card.rejected &&
|
||||
!card.aborted &&
|
||||
card.exitCode !== undefined &&
|
||||
card.exitCode !== 0
|
||||
) {
|
||||
parts.push(`exit ${card.exitCode}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function formatArgsSummary(args: unknown): string {
|
||||
if (typeof args === "string") return args.length > 60 ? `${args.slice(0, 60)}…` : args;
|
||||
if (args && typeof args === "object") {
|
||||
@@ -87,16 +153,23 @@ function formatArgsSummary(args: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatMeta(card: ToolCardData): string {
|
||||
const parts: string[] = [];
|
||||
if (card.elapsedMs > 0) parts.push(`${(card.elapsedMs / 1000).toFixed(2)}s`);
|
||||
if (card.aborted) {
|
||||
parts.push("aborted");
|
||||
} else if (card.done) {
|
||||
if (card.exitCode === 0) parts.push("exit 0");
|
||||
else if (card.exitCode !== undefined) parts.push(`exit ${card.exitCode}`);
|
||||
} else {
|
||||
parts.push("running");
|
||||
const INPUT_SIZE_THRESHOLD = 1024;
|
||||
|
||||
/** Largest string field on args, when above threshold. Surfaces input bulk for write_file (content), edit_file (replace), run_command (long stdin), etc. without per-tool special cases. */
|
||||
export function largestStringInputBytes(args: unknown): number | null {
|
||||
let max = 0;
|
||||
if (typeof args === "string") {
|
||||
max = args.length;
|
||||
} else if (args && typeof args === "object") {
|
||||
for (const v of Object.values(args as Record<string, unknown>)) {
|
||||
if (typeof v === "string" && v.length > max) max = v.length;
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? `· ${parts.join(" · ")}` : "";
|
||||
return max >= INPUT_SIZE_THRESHOLD ? max : null;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { BarRow } from "../primitives/BarRow.js";
|
||||
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 { CardHeader } from "./CardHeader.js";
|
||||
import { FG, TONE, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const BAR_CELLS = 30;
|
||||
|
||||
@@ -31,68 +31,72 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
|
||||
const promptRatio = card.tokens.prompt / cap;
|
||||
const reasonRatio = card.tokens.reason / cap;
|
||||
const outputRatio = card.tokens.output / cap;
|
||||
const elapsed = card.elapsedMs !== undefined ? ` · ${(card.elapsedMs / 1000).toFixed(1)}s` : "";
|
||||
const meta = `${formatCNY(card.cost)}${elapsed}`;
|
||||
|
||||
const headerMeta: string[] = [`turn ${card.turn}`, formatCost(card.cost, card.balanceCurrency)];
|
||||
if (card.elapsedMs !== undefined) headerMeta.push(`${(card.elapsedMs / 1000).toFixed(1)}s`);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<CardHeader tone="usage" glyph="Σ" title="Usage" subtitle={`turn ${card.turn}`} meta={meta} />
|
||||
<BarRow tone="usage" indent={0} />
|
||||
<BarRow tone="usage">
|
||||
<Text color={FG.sub}>{"prompt "}</Text>
|
||||
<Card tone={FG.meta}>
|
||||
<CardHeader glyph="Σ" tone={FG.meta} title="usage" meta={headerMeta} />
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.sub}>prompt</Text>
|
||||
{bar(promptRatio, TONE.brand)}
|
||||
<Text bold color={FG.body}>{` ${card.tokens.prompt.toLocaleString()}`}</Text>
|
||||
<Text color={FG.faint}>{` / 1M · ${(promptRatio * 100).toFixed(1)}%`}</Text>
|
||||
</BarRow>
|
||||
<BarRow tone="usage">
|
||||
<Text color={FG.sub}>{"reason "}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{card.tokens.prompt.toLocaleString()}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{`/ 1M · ${(promptRatio * 100).toFixed(1)}%`}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.sub}>reason</Text>
|
||||
{bar(reasonRatio, TONE.accent)}
|
||||
<Text bold color={FG.body}>{` ${card.tokens.reason.toLocaleString()}`}</Text>
|
||||
</BarRow>
|
||||
<BarRow tone="usage">
|
||||
<Text color={FG.sub}>{"output "}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{card.tokens.reason.toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.sub}>output</Text>
|
||||
{bar(outputRatio, TONE.brand)}
|
||||
<Text bold color={FG.body}>{` ${card.tokens.output.toLocaleString()}`}</Text>
|
||||
</BarRow>
|
||||
<BarRow tone="usage" indent={0} />
|
||||
<BarRow tone="usage">
|
||||
<Text color={FG.sub}>{"cache hit "}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{card.tokens.output.toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.sub}>cache </Text>
|
||||
{bar(card.cacheHit, TONE.ok)}
|
||||
<Text bold color={TONE.ok}>{` ${(card.cacheHit * 100).toFixed(1)}%`}</Text>
|
||||
</BarRow>
|
||||
<BarRow tone="usage" indent={0} />
|
||||
<BarRow tone="usage">
|
||||
<Text color={FG.faint}>{"session "}</Text>
|
||||
<Text bold color={FG.body}>{`⛁ ${formatCNY(card.sessionCost, 3)}`}</Text>
|
||||
{card.balance !== undefined && (
|
||||
<Text bold color={TONE.ok}>{`${(card.cacheHit * 100).toFixed(1)}%`}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.faint}>session</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{`⛁ ${formatCost(card.sessionCost, card.balanceCurrency, 3)}`}
|
||||
</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<>
|
||||
<Text color={FG.meta}>{" · "}</Text>
|
||||
<Text color={FG.faint}>{"balance "}</Text>
|
||||
<Text bold color={TONE.brand}>{`¥${card.balance.toFixed(2)}`}</Text>
|
||||
<Text color={FG.faint}>· balance</Text>
|
||||
<Text bold color={TONE.brand}>
|
||||
{formatBalance(card.balance, card.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</BarRow>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactUsageRow({ card }: { card: UsageCardData }): React.ReactElement {
|
||||
const elapsed = card.elapsedMs !== undefined ? ` · ${(card.elapsedMs / 1000).toFixed(1)}s` : "";
|
||||
const elapsed = card.elapsedMs !== undefined ? ` · ${(card.elapsedMs / 1000).toFixed(1)}s` : "";
|
||||
return (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` Σ turn ${card.turn} · `}</Text>
|
||||
<Text
|
||||
color={FG.meta}
|
||||
>{`${compactNum(card.tokens.prompt)} prompt · ${compactNum(card.tokens.output)} out`}</Text>
|
||||
<Text color={FG.faint}>{" · cache "}</Text>
|
||||
<Box flexDirection="row" gap={1} marginTop={1}>
|
||||
<Text color={FG.meta}>Σ</Text>
|
||||
<Text color={FG.faint}>{`turn ${card.turn}`}</Text>
|
||||
<Text color={FG.meta}>
|
||||
{`· ${compactNum(card.tokens.prompt)} prompt · ${compactNum(card.tokens.output)} out`}
|
||||
</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>
|
||||
{card.balance !== undefined && (
|
||||
<>
|
||||
<Text color={FG.faint}>{" · "}</Text>
|
||||
<Text color={TONE.brand}>{`¥${card.balance.toFixed(2)}`}</Text>
|
||||
</>
|
||||
)}
|
||||
<Text color={FG.faint}>{`· ${formatCost(card.cost, card.balanceCurrency)}${elapsed}`}</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<Text color={TONE.brand}>{`· ${formatBalance(card.balance, card.balanceCurrency)}`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,23 @@
|
||||
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 { Markdown } from "../markdown.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { UserCard as UserCardData } from "../state/cards.js";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
import { formatRelativeTime } from "./time.js";
|
||||
|
||||
export function UserCard({ card }: { card: UserCardData }): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text>{" "}</Text>
|
||||
<Text color={FG.meta}>◇</Text>
|
||||
<Text bold color={FG.sub}>
|
||||
{" you"}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{` · ${formatRelativeTime(card.ts)}`}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" paddingLeft={4}>
|
||||
<Markdown text={card.text} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Card tone={TONE.accent}>
|
||||
<CardHeader
|
||||
glyph="›"
|
||||
tone={TONE.accent}
|
||||
title="you"
|
||||
titleColor={FG.sub}
|
||||
meta={[formatRelativeTime(card.ts)]}
|
||||
/>
|
||||
<Markdown text={card.text} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,26 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { BarRow } from "../primitives/BarRow.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { WarnCard as WarnCardData } from "../state/cards.js";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
import { CardHeader } from "./CardHeader.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
export function WarnCard({ card }: { card: WarnCardData }): React.ReactElement {
|
||||
const showBody = card.message.length > 0;
|
||||
const messageLines = card.message.length > 0 ? card.message.split("\n") : [];
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Card tone={TONE.warn}>
|
||||
<CardHeader
|
||||
tone="warn"
|
||||
glyph="⚠"
|
||||
tone={TONE.warn}
|
||||
title={card.title}
|
||||
meta={card.detail ? `· ${card.detail}` : undefined}
|
||||
meta={card.detail ? [card.detail] : undefined}
|
||||
/>
|
||||
{showBody && (
|
||||
<>
|
||||
<BarRow tone="warn" indent={0} />
|
||||
{card.message.split("\n").map((line, i) => (
|
||||
<BarRow key={`${card.id}:${i}`} tone="warn">
|
||||
<Text color={FG.body}>{line}</Text>
|
||||
</BarRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{messageLines.map((line, i) => (
|
||||
<Text key={`${card.id}:${i}`} color={FG.body}>
|
||||
{line || " "}
|
||||
</Text>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
export const STATUS = {
|
||||
ok: "✓",
|
||||
running: "▶",
|
||||
queued: "○",
|
||||
failed: "✗",
|
||||
blocked: "!",
|
||||
collapsed: "▸",
|
||||
expanded: "▾",
|
||||
focused: "●",
|
||||
} as const;
|
||||
|
||||
export const STRUCT = {
|
||||
bar: "▎",
|
||||
thinBar: "▏",
|
||||
rule: "─",
|
||||
dot: "·",
|
||||
arrow: "›",
|
||||
} as const;
|
||||
|
||||
export const DENSITY = {
|
||||
full: "█",
|
||||
high: "▓",
|
||||
mid: "▒",
|
||||
low: "░",
|
||||
on: "▰",
|
||||
off: "▱",
|
||||
} as const;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,7 +27,7 @@ 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" };
|
||||
}
|
||||
@@ -50,7 +43,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 +70,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<
|
||||
@@ -25,9 +25,6 @@ export interface ToolEventContext {
|
||||
setPendingChoice: Dispatch<
|
||||
SetStateAction<{ question: string; options: ChoiceOption[]; allowCustom: boolean } | null>
|
||||
>;
|
||||
setPendingCheckpoint: Dispatch<
|
||||
SetStateAction<{ stepId: string; title?: string; completed: number; total: number } | null>
|
||||
>;
|
||||
planStepsRef: MutableRefObject<PlanStep[] | null>;
|
||||
completedStepIdsRef: MutableRefObject<Set<string>>;
|
||||
planBodyRef: MutableRefObject<string | null>;
|
||||
@@ -55,125 +52,9 @@ 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> & { error?: string };
|
||||
const parsed = JSON.parse(ev.content) as Partial<StepCompletion>;
|
||||
const stepId = parsed.stepId;
|
||||
if (parsed.kind === "step_completed" && typeof stepId === "string") {
|
||||
ctx.completedStepIdsRef.current.add(stepId);
|
||||
@@ -192,9 +73,6 @@ export function handleToolEvent(ev: LoopEvent, ctx: ToolEventContext): void {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof parsed.error === "string" && parsed.error.startsWith("PlanCheckpointError:")) {
|
||||
ctx.setPendingCheckpoint({ stepId, title, completed, total });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* malformed payload — skip the progress row */
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type MutableRefObject, useCallback } from "react";
|
||||
import {
|
||||
type ApplyResult,
|
||||
type EditBlock,
|
||||
type EditSnapshot,
|
||||
applyEditBlocks,
|
||||
snapshotBeforeEdits,
|
||||
} from "../../../code/edit-blocks.js";
|
||||
import { clearPendingEdits, savePendingEdits } from "../../../code/pending-edits.js";
|
||||
import { formatEditResults, partitionEdits } from "../edit-history.js";
|
||||
|
||||
export interface UseCodeModeResult {
|
||||
/** /apply callback. Empty `indices` means "all"; specific 1-based indices apply only those. */
|
||||
codeApply: (indices?: readonly number[]) => string;
|
||||
/** /discard callback. Same indices semantics as codeApply. */
|
||||
codeDiscard: (indices?: readonly number[]) => string;
|
||||
}
|
||||
|
||||
export interface UseCodeModeOptions {
|
||||
codeMode: boolean;
|
||||
pendingEdits: MutableRefObject<EditBlock[]>;
|
||||
currentRootDir: string;
|
||||
session: string | null;
|
||||
syncPendingCount: () => void;
|
||||
recordEdit: (
|
||||
source: string,
|
||||
blocks: readonly EditBlock[],
|
||||
results: readonly ApplyResult[],
|
||||
snaps: readonly EditSnapshot[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** Slash-command callbacks for `/apply` and `/discard` over the pending-edits queue. Owns the partition / snapshot / save / sync sequence; AppInner just forwards the strings to its log. */
|
||||
export function useCodeMode(opts: UseCodeModeOptions): UseCodeModeResult {
|
||||
const { codeMode, pendingEdits, currentRootDir, session, syncPendingCount, recordEdit } = opts;
|
||||
|
||||
const codeApply = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) {
|
||||
return "nothing pending — the model hasn't proposed edits since the last /apply or /discard.";
|
||||
}
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing applied. Use /apply with no args to commit them all.";
|
||||
}
|
||||
const snaps = snapshotBeforeEdits(selected, currentRootDir);
|
||||
const results = applyEditBlocks(selected, currentRootDir);
|
||||
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
if (anyApplied) recordEdit("review-apply", selected, results, snaps);
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? `\n▸ ${remaining.length} edit block(s) still pending — /apply or /discard to clear them.`
|
||||
: "";
|
||||
return formatEditResults(results) + tail;
|
||||
},
|
||||
[codeMode, currentRootDir, session, syncPendingCount, recordEdit, pendingEdits],
|
||||
);
|
||||
|
||||
const codeDiscard = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) return "nothing pending to discard.";
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing discarded.";
|
||||
}
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? ` (${remaining.length} block(s) still pending)`
|
||||
: ". Nothing was written to disk.";
|
||||
return `▸ discarded ${selected.length} pending edit block(s)${tail}`;
|
||||
},
|
||||
[session, syncPendingCount, pendingEdits],
|
||||
);
|
||||
|
||||
return { codeApply, codeDiscard };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export interface UseInputRecallResult {
|
||||
recallPrev: () => void;
|
||||
recallNext: () => void;
|
||||
pushHistory: (text: string) => void;
|
||||
/** Reset cursor to the "fresh input" position — call after a successful submit. */
|
||||
resetCursor: () => void;
|
||||
}
|
||||
|
||||
/** Bash-style ↑/↓ recall over a turn-local prompt history. Cursor is `useRef` so toggles don't re-render. */
|
||||
export function useInputRecall(setInput: (s: string) => void): UseInputRecallResult {
|
||||
const promptHistory = useRef<string[]>([]);
|
||||
const historyCursor = useRef<number>(-1);
|
||||
|
||||
const recallPrev = useCallback(() => {
|
||||
const hist = promptHistory.current;
|
||||
if (hist.length === 0) return;
|
||||
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(hist[hist.length - 1 - nextCursor] ?? "");
|
||||
}, [setInput]);
|
||||
|
||||
const recallNext = useCallback(() => {
|
||||
if (historyCursor.current < 0) return;
|
||||
const hist = promptHistory.current;
|
||||
const nextCursor = historyCursor.current - 1;
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
|
||||
}, [setInput]);
|
||||
|
||||
const pushHistory = useCallback((text: string) => {
|
||||
promptHistory.current.push(text);
|
||||
}, []);
|
||||
|
||||
const resetCursor = useCallback(() => {
|
||||
historyCursor.current = -1;
|
||||
}, []);
|
||||
|
||||
return { recallPrev, recallNext, pushHistory, resetCursor };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Scrollback } from "./useScrollback.js";
|
||||
|
||||
interface ActiveLoop {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
}
|
||||
|
||||
export interface ActiveLoopSnapshot {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
nextFireMs: number;
|
||||
}
|
||||
|
||||
export interface UseLoopModeResult {
|
||||
startLoop: (intervalMs: number, prompt: string) => void;
|
||||
stopLoop: () => void;
|
||||
/** Snapshot for the `/loop` (no-arg) status branch. Returns null when no loop is active. */
|
||||
getLoopStatus: () => ActiveLoopSnapshot | null;
|
||||
/** Cheap predicate — used by handleSubmit's cancel-on-user-input guard and by apply-slash-result. */
|
||||
isLoopActive: () => boolean;
|
||||
/** True only during the timer's `handleSubmit` invocation — tells handleSubmit's cancel guard to skip itself so the loop's own re-submit doesn't kill the loop. */
|
||||
isLoopFiring: () => boolean;
|
||||
/** Reset by handleSubmit at the top of every call so the firing flag is one-shot. */
|
||||
clearFiringFlag: () => void;
|
||||
/** Reactive state for the LoopStatusRow render — null when no loop is active. */
|
||||
activeLoop: ActiveLoop | null;
|
||||
}
|
||||
|
||||
export interface UseLoopModeOptions {
|
||||
log: Scrollback;
|
||||
busyRef: MutableRefObject<boolean>;
|
||||
/** Forward-ref to the latest `handleSubmit` — the closure shifts as state changes, so the timer dereferences fresh on each fire. */
|
||||
handleSubmitRef: MutableRefObject<((raw: string) => Promise<void>) | null>;
|
||||
}
|
||||
|
||||
/** Owns the active /loop config + its setTimeout-based scheduler. Re-issuing /loop replaces the slot; cancellation is centralized in stopLoop. */
|
||||
export function useLoopMode(opts: UseLoopModeOptions): UseLoopModeResult {
|
||||
const { log, busyRef, handleSubmitRef } = opts;
|
||||
const [activeLoop, setActiveLoop] = useState<ActiveLoop | null>(null);
|
||||
const activeLoopRef = useRef<ActiveLoop | null>(null);
|
||||
const loopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loopFiringRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
activeLoopRef.current = activeLoop;
|
||||
}, [activeLoop]);
|
||||
|
||||
const stopLoop = useCallback(() => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
setActiveLoop(null);
|
||||
log.pushInfo(`▸ loop stopped (after ${cur.iter} iter${cur.iter === 1 ? "" : "s"}).`);
|
||||
}, [log]);
|
||||
|
||||
const startLoop = useCallback((intervalMs: number, prompt: string) => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
setActiveLoop({
|
||||
prompt,
|
||||
intervalMs,
|
||||
nextFireAt: Date.now() + intervalMs,
|
||||
iter: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getLoopStatus = useCallback((): ActiveLoopSnapshot | null => {
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return null;
|
||||
return {
|
||||
prompt: cur.prompt,
|
||||
intervalMs: cur.intervalMs,
|
||||
iter: cur.iter,
|
||||
nextFireMs: Math.max(0, cur.nextFireAt - Date.now()),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isLoopActive = useCallback(() => activeLoopRef.current !== null, []);
|
||||
const isLoopFiring = useCallback(() => loopFiringRef.current, []);
|
||||
const clearFiringFlag = useCallback(() => {
|
||||
loopFiringRef.current = false;
|
||||
}, []);
|
||||
|
||||
// /loop scheduler. Re-runs whenever activeLoop's `nextFireAt` shifts —
|
||||
// either because startLoop set a fresh schedule or because a previous
|
||||
// firing bumped the next-fire time. Cleanup clears the in-flight
|
||||
// timer so a stopLoop / replacement doesn't leak a fire after cancel.
|
||||
useEffect(() => {
|
||||
if (!activeLoop) return;
|
||||
const delay = Math.max(0, activeLoop.nextFireAt - Date.now());
|
||||
const timer = setTimeout(async () => {
|
||||
loopTimerRef.current = null;
|
||||
// Skip the firing entirely when a prior turn is still running.
|
||||
// Re-arm in 1s so the loop catches up the moment busy clears,
|
||||
// rather than waiting a full interval after a slow turn.
|
||||
if (busyRef.current) {
|
||||
setActiveLoop((cur) => (cur ? { ...cur, nextFireAt: Date.now() + 1000 } : cur));
|
||||
return;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
const nextIter = cur.iter + 1;
|
||||
// Schedule the NEXT firing now (independent of how long this turn
|
||||
// takes). Keeps the cadence honest even when individual turns run
|
||||
// long.
|
||||
setActiveLoop((c) =>
|
||||
c ? { ...c, iter: nextIter, nextFireAt: Date.now() + cur.intervalMs } : c,
|
||||
);
|
||||
log.pushInfo(`▸ /loop iter ${nextIter} → ${cur.prompt}`);
|
||||
loopFiringRef.current = true;
|
||||
try {
|
||||
await handleSubmitRef.current?.(cur.prompt);
|
||||
} catch {
|
||||
// Persistent submission errors → kill the loop rather than spam
|
||||
// the screen. User can re-issue /loop once they fix the cause.
|
||||
stopLoop();
|
||||
} finally {
|
||||
loopFiringRef.current = false;
|
||||
}
|
||||
}, delay);
|
||||
loopTimerRef.current = timer;
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeLoop, stopLoop, log, busyRef, handleSubmitRef]);
|
||||
|
||||
return {
|
||||
startLoop,
|
||||
stopLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
activeLoop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { type MutableRefObject, useCallback, useEffect } from "react";
|
||||
|
||||
/** Ctrl+C / SIGINT → flush transcript + `process.exit(0)`. We call `process.exit` directly rather than Ink's `exit()` because the singleton stdin reader keeps a `data` listener attached — `exit()` would unmount the React tree but leave the event loop alive and the terminal would hang. */
|
||||
export function useQuit(transcriptRef: MutableRefObject<WriteStream | null>): () => void {
|
||||
const quitProcess = useCallback(() => {
|
||||
transcriptRef.current?.end();
|
||||
process.exit(0);
|
||||
}, [transcriptRef]);
|
||||
|
||||
useEffect(() => {
|
||||
process.on("SIGINT", quitProcess);
|
||||
return () => {
|
||||
process.off("SIGINT", quitProcess);
|
||||
};
|
||||
}, [quitProcess]);
|
||||
|
||||
return quitProcess;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export interface Scrollback {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
elapsedMs?: number;
|
||||
}): string;
|
||||
showPlan(args: {
|
||||
@@ -219,6 +220,7 @@ export function useScrollback(): Scrollback {
|
||||
cost: args.cost,
|
||||
sessionCost: args.sessionCost,
|
||||
balance: args.balance,
|
||||
balanceCurrency: args.balanceCurrency,
|
||||
elapsedMs: args.elapsedMs,
|
||||
});
|
||||
return id;
|
||||
|
||||