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 |
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,124 @@
|
||||
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.
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -264,6 +264,32 @@ export const en = {
|
||||
colName: "name",
|
||||
colDesc: "description",
|
||||
colUri: "uri",
|
||||
marketplace: "marketplace",
|
||||
marketplaceSearch: "search the registry…",
|
||||
marketplaceLoading: "loading registry…",
|
||||
marketplaceMore: "load 5 more pages",
|
||||
marketplaceMoreLabel: "load 50 more · showing {shown} / {total}",
|
||||
marketplaceMoreHint: "fetches more pages from the registry",
|
||||
marketplaceMoreCachedHint: "more entries already cached locally",
|
||||
marketplaceExhausted: "all pages loaded",
|
||||
marketplaceExhaustedFull: "showing all {total} entries — registry exhausted",
|
||||
marketplaceCount: "{loaded} loaded · {matched} match · source: {source}{cached}",
|
||||
marketplaceCachedSuffix: " · cached",
|
||||
marketplaceNoMatches: "No matches. Try different terms or load more pages.",
|
||||
marketplaceInstall: "Install",
|
||||
marketplacePickHint: "Pick a server on the left, then Install.",
|
||||
marketplaceInstalled: "installed → {spec}",
|
||||
marketplaceInstalledBridged: "installed + bridged → {spec}",
|
||||
marketplaceAlready: "already installed",
|
||||
marketplaceNeedsEnv: "needs env: {names}",
|
||||
marketplaceSourceTag: "[{source}]",
|
||||
marketplaceNoInstall: "smithery listing — install metadata not exposed; use `npx -y @smithery/cli install {name}` directly",
|
||||
marketplaceFetchOnInstall: "Smithery listing — install detail fetched on Install. http servers map to streamable-http remotes; stdio servers run via @smithery/cli.",
|
||||
marketplaceInstalledBadge: "installed",
|
||||
marketplaceUninstall: "Uninstall",
|
||||
marketplaceEnvTitle: "Required environment variables",
|
||||
marketplaceEnvHint: "Set these in your shell before next `reasonix code` so the bridged server can authenticate.",
|
||||
marketplaceRestartHint: "Spec written to ~/.reasonix/config.json. Restart `reasonix code` to bridge the server (live hot-reload is on the roadmap).",
|
||||
},
|
||||
memory: {
|
||||
loading: "loading memory…",
|
||||
|
||||
@@ -264,6 +264,32 @@ export const zhCN = {
|
||||
colName: "名称",
|
||||
colDesc: "描述",
|
||||
colUri: "URI",
|
||||
marketplace: "市场",
|
||||
marketplaceSearch: "搜索注册表…",
|
||||
marketplaceLoading: "加载注册表…",
|
||||
marketplaceMore: "再加载 5 页",
|
||||
marketplaceMoreLabel: "再加载 50 条 · 当前 {shown} / {total}",
|
||||
marketplaceMoreHint: "需要从远端注册表再拉几页",
|
||||
marketplaceMoreCachedHint: "本地缓存已有更多条目",
|
||||
marketplaceExhausted: "已加载全部页",
|
||||
marketplaceExhaustedFull: "已展示全部 {total} 条 — 注册表耗尽",
|
||||
marketplaceCount: "已载入 {loaded} · 匹配 {matched} · 来源:{source}{cached}",
|
||||
marketplaceCachedSuffix: " · 缓存中",
|
||||
marketplaceNoMatches: "无匹配结果。换关键词或加载更多页。",
|
||||
marketplaceInstall: "安装",
|
||||
marketplacePickHint: "在左侧选择服务器,然后点安装。",
|
||||
marketplaceInstalled: "已安装 → {spec}",
|
||||
marketplaceInstalledBridged: "已安装并桥接 → {spec}",
|
||||
marketplaceAlready: "已安装过",
|
||||
marketplaceNeedsEnv: "需设置环境变量:{names}",
|
||||
marketplaceSourceTag: "[{source}]",
|
||||
marketplaceNoInstall: "Smithery 列表项 — 不暴露安装元数据;请直接 `npx -y @smithery/cli install {name}`",
|
||||
marketplaceFetchOnInstall: "Smithery 列表 — 安装时再拉详情。HTTP 服务映射为 streamable-http 远端;stdio 服务通过 @smithery/cli 运行。",
|
||||
marketplaceInstalledBadge: "已安装",
|
||||
marketplaceUninstall: "卸载",
|
||||
marketplaceEnvTitle: "必需的环境变量",
|
||||
marketplaceEnvHint: "下次启动 `reasonix code` 之前在 shell 里设好,桥接的服务器才能正常鉴权。",
|
||||
marketplaceRestartHint: "已写入 ~/.reasonix/config.json。重启 `reasonix code` 后服务器才会真正桥接(热重载在路线图上)。",
|
||||
},
|
||||
memory: {
|
||||
loading: "加载记忆…",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtNum } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface McpServer {
|
||||
label: string;
|
||||
@@ -20,6 +20,60 @@ interface McpData {
|
||||
servers: McpServer[];
|
||||
}
|
||||
|
||||
interface RegistryInstall {
|
||||
runtime: string;
|
||||
packageId?: string;
|
||||
version?: string;
|
||||
transport: string;
|
||||
url?: string;
|
||||
requiredEnv?: string[];
|
||||
extraArgs?: string[];
|
||||
}
|
||||
|
||||
interface RegistryEntryDto {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
source: "official" | "smithery" | "local";
|
||||
install?: RegistryInstall;
|
||||
popularity?: number;
|
||||
homepage?: string;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
/** Mirror of src/mcp/registry-fetch.ts:specStringFor — kept in sync to detect already-installed state without an extra round-trip. */
|
||||
function specForEntry(e: RegistryEntryDto): string | null {
|
||||
if (!e.install) return null;
|
||||
const localName = e.name.split("/").pop() ?? e.name;
|
||||
const safe = localName.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/^-+|-+$/g, "") || "mcp";
|
||||
const trail = e.install.extraArgs?.length ? ` ${e.install.extraArgs.join(" ")}` : "";
|
||||
if (e.install.runtime === "remote" && e.install.url) {
|
||||
if (e.install.transport === "streamable-http") return `${safe}=streamable+${e.install.url}`;
|
||||
return `${safe}=${e.install.url}`;
|
||||
}
|
||||
if (e.install.runtime === "npm" && e.install.packageId) {
|
||||
const pinned = e.install.version
|
||||
? `${e.install.packageId}@${e.install.version}`
|
||||
: e.install.packageId;
|
||||
return `${safe}=npx -y ${pinned}${trail}`;
|
||||
}
|
||||
if (e.install.runtime === "pypi" && e.install.packageId) {
|
||||
return `${safe}=uvx ${e.install.packageId}${trail}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface RegistryListResponse {
|
||||
source: "official" | "smithery" | "local";
|
||||
fromCache: boolean;
|
||||
fetchedAt: number;
|
||||
loaded: number;
|
||||
hasMore: boolean;
|
||||
matched: number;
|
||||
entries: RegistryEntryDto[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function specLabel(spec: string): string {
|
||||
const eq = spec.indexOf("=");
|
||||
return eq > 0 ? spec.slice(0, eq) : spec;
|
||||
@@ -30,7 +84,7 @@ function specCommand(spec: string): string {
|
||||
return eq > 0 ? spec.slice(eq + 1) : spec;
|
||||
}
|
||||
|
||||
type McpFilter = "all" | "live" | "unbridged";
|
||||
type McpFilter = "all" | "live" | "unbridged" | "marketplace";
|
||||
|
||||
export function McpPanel() {
|
||||
useLang();
|
||||
@@ -43,6 +97,70 @@ export function McpPanel() {
|
||||
const [open, setOpen] = useState<McpServer | null>(null);
|
||||
const [openUnbridged, setOpenUnbridged] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<McpFilter>("all");
|
||||
const [registry, setRegistry] = useState<RegistryListResponse | null>(null);
|
||||
const [registryQuery, setRegistryQuery] = useState("");
|
||||
const [registryLoading, setRegistryLoading] = useState(false);
|
||||
const [openRegistry, setOpenRegistry] = useState<RegistryEntryDto | null>(null);
|
||||
/** Display cap — grows by 50 each "load more" click. Server caps response size at this. */
|
||||
const [displayLimit, setDisplayLimit] = useState(50);
|
||||
|
||||
const loadRegistry = useCallback(async (q: string, pages: number, limit: number) => {
|
||||
setRegistryLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
params.set("pages", String(pages));
|
||||
params.set("maxPages", String(Math.max(20, pages)));
|
||||
params.set("limit", String(limit));
|
||||
const r = await api<RegistryListResponse>(`/mcp/registry?${params.toString()}`);
|
||||
setRegistry(r);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setRegistryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== "marketplace") return;
|
||||
if (registry) return;
|
||||
void loadRegistry("", 1, displayLimit);
|
||||
}, [filter, registry, loadRegistry, displayLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== "marketplace") return;
|
||||
// Reset the display cap whenever the user retypes; new query = fresh top-50.
|
||||
setDisplayLimit(50);
|
||||
const handle = setTimeout(() => void loadRegistry(registryQuery, 1, 50), 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [registryQuery, filter, loadRegistry]);
|
||||
|
||||
const installFromRegistry = useCallback(async (entry: RegistryEntryDto) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api<{
|
||||
added: boolean;
|
||||
alreadyPresent?: boolean;
|
||||
bridged?: boolean;
|
||||
spec: string;
|
||||
}>("/mcp/registry/install", { method: "POST", body: { name: entry.name } });
|
||||
if (r.alreadyPresent) {
|
||||
setInfo(t("mcp.marketplaceAlready"));
|
||||
} else if (r.bridged) {
|
||||
setInfo(t("mcp.marketplaceInstalledBridged", { spec: r.spec }));
|
||||
} else {
|
||||
setInfo(t("mcp.marketplaceInstalled", { spec: r.spec }));
|
||||
}
|
||||
setTimeout(() => setInfo(null), 5000);
|
||||
// Reload BOTH live + spec lists since hot-reload should have
|
||||
// attached the new bridge.
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -64,9 +182,7 @@ export function McpPanel() {
|
||||
method: "POST",
|
||||
body: { spec: newSpec.trim() },
|
||||
});
|
||||
setInfo(
|
||||
r.requiresRestart ? t("mcp.savedRestart") : t("mcp.saved"),
|
||||
);
|
||||
setInfo(r.requiresRestart ? t("mcp.savedRestart") : t("mcp.saved"));
|
||||
setTimeout(() => setInfo(null), 4000);
|
||||
setNewSpec("");
|
||||
await load();
|
||||
@@ -95,15 +211,17 @@ export function McpPanel() {
|
||||
[load],
|
||||
);
|
||||
|
||||
if (!data && !error) return html`<div class="card" style="color:var(--fg-3)">${t("mcp.loading")}</div>`;
|
||||
if (!data && !error)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("mcp.loading")}</div>`;
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
|
||||
const liveCount = data.servers.length;
|
||||
const unbridgedSpecs = (specs ?? []).filter((spec) => !data.servers.some((s) => s.spec === spec));
|
||||
const unbridgedCount = unbridgedSpecs.length;
|
||||
const showLive = filter !== "unbridged";
|
||||
const showUnbridged = filter !== "live";
|
||||
const showLive = filter === "all" || filter === "live";
|
||||
const showUnbridged = filter === "all" || filter === "unbridged";
|
||||
const showMarketplace = filter === "marketplace";
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
@@ -116,29 +234,82 @@ export function McpPanel() {
|
||||
<span class=${`chip-f ${filter === "all" ? "active" : ""}`} onClick=${() => setFilter("all")}>${t("mcp.all")} <span class="ct">${liveCount + unbridgedCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "live" ? "active" : ""}`} onClick=${() => setFilter("live")}>${t("mcp.live")} <span class="ct">${liveCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "unbridged" ? "active" : ""}`} onClick=${() => setFilter("unbridged")}>${t("mcp.unbridged")} <span class="ct">${unbridgedCount}</span></span>
|
||||
<span class=${`chip-f ${filter === "marketplace" ? "active" : ""}`} onClick=${() => setFilter("marketplace")}>${t("mcp.marketplace")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 12px;display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("mcp.specPlaceholder")}
|
||||
value=${newSpec}
|
||||
onInput=${(e: Event) => setNewSpec((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;font-size:11px"
|
||||
/>
|
||||
<button class="btn primary" disabled=${busy || !newSpec.trim()} onClick=${addSpec}>+</button>
|
||||
</div>
|
||||
${
|
||||
showMarketplace
|
||||
? html`
|
||||
<div style="padding:8px 12px;display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("mcp.marketplaceSearch")}
|
||||
value=${registryQuery}
|
||||
onInput=${(e: Event) => setRegistryQuery((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;font-size:11px"
|
||||
/>
|
||||
</div>
|
||||
${
|
||||
registry
|
||||
? html`<div style="padding:0 12px 6px;font-size:11px;color:var(--fg-3)">
|
||||
${t("mcp.marketplaceCount", {
|
||||
loaded: registry.loaded,
|
||||
matched: registry.matched,
|
||||
source: registry.source,
|
||||
cached: registry.fromCache ? t("mcp.marketplaceCachedSuffix") : "",
|
||||
})}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
`
|
||||
: html`
|
||||
<div style="padding:8px 12px;display:flex;gap:6px">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("mcp.specPlaceholder")}
|
||||
value=${newSpec}
|
||||
onInput=${(e: Event) => setNewSpec((e.target as HTMLInputElement).value)}
|
||||
style="flex:1;font-size:11px"
|
||||
/>
|
||||
<button class="btn primary" disabled=${busy || !newSpec.trim()} onClick=${addSpec}>+</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
${info ? html`<div style="padding:0 12px 8px"><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err" style="margin:0 12px 8px">${error}</div>` : null}
|
||||
|
||||
<div class="ssl-rows">
|
||||
${
|
||||
liveCount === 0 && unbridgedCount === 0
|
||||
!showMarketplace && liveCount === 0 && unbridgedCount === 0
|
||||
? html`<div style="color:var(--fg-3);padding:14px;font-size:12px">
|
||||
${t("mcp.noServers")}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
showMarketplace
|
||||
? renderMarketplaceRows({
|
||||
registry,
|
||||
registryLoading,
|
||||
openRegistry,
|
||||
setOpenRegistry: (e) => {
|
||||
setOpenRegistry(e);
|
||||
setOpen(null);
|
||||
setOpenUnbridged(null);
|
||||
},
|
||||
loadMore: () => {
|
||||
const nextLimit = displayLimit + 50;
|
||||
setDisplayLimit(nextLimit);
|
||||
// Pages: walk far enough to fill the new cap (each page ≈ 30
|
||||
// entries) plus a few-page lookahead so the next click also
|
||||
// has fresh data.
|
||||
const pagesNeeded = Math.ceil(nextLimit / 30) + 3;
|
||||
void loadRegistry(registryQuery, pagesNeeded, nextLimit);
|
||||
},
|
||||
installedSpecs: new Set(specs ?? []),
|
||||
})
|
||||
: null
|
||||
}
|
||||
${
|
||||
showLive
|
||||
? data.servers.map(
|
||||
@@ -182,8 +353,20 @@ export function McpPanel() {
|
||||
|
||||
<div class="sessions-detail">
|
||||
${
|
||||
openUnbridged != null
|
||||
? html`
|
||||
openRegistry != null
|
||||
? renderRegistryDetail({
|
||||
entry: openRegistry,
|
||||
busy,
|
||||
installedSpec: (() => {
|
||||
const spec = specForEntry(openRegistry);
|
||||
return spec && (specs ?? []).includes(spec) ? spec : null;
|
||||
})(),
|
||||
onInstall: () => installFromRegistry(openRegistry),
|
||||
onUninstall: (spec: string) => removeSpec(spec),
|
||||
onClose: () => setOpenRegistry(null),
|
||||
})
|
||||
: openUnbridged != null
|
||||
? html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${specLabel(openUnbridged)}</span>
|
||||
<span class="ws"><span class="pill">${t("mcp.unbridgedTitle")}</span></span>
|
||||
@@ -207,11 +390,11 @@ export function McpPanel() {
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${t("mcp.pickHint")}
|
||||
: open == null
|
||||
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
|
||||
${showMarketplace ? t("mcp.marketplacePickHint") : t("mcp.pickHint")}
|
||||
</div>`
|
||||
: html`
|
||||
: html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${open.label}</span>
|
||||
<span class="ws">${open.serverInfo?.name ?? "—"} ${open.serverInfo?.version ? `v${open.serverInfo.version}` : ""} · ${open.protocolVersion ?? "—"}</span>
|
||||
@@ -295,3 +478,198 @@ export function McpPanel() {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface MarketplaceRowsArgs {
|
||||
registry: RegistryListResponse | null;
|
||||
registryLoading: boolean;
|
||||
openRegistry: RegistryEntryDto | null;
|
||||
setOpenRegistry: (entry: RegistryEntryDto) => void;
|
||||
loadMore: () => void;
|
||||
installedSpecs: Set<string>;
|
||||
}
|
||||
|
||||
function renderLoadMoreFooter({
|
||||
registry,
|
||||
registryLoading,
|
||||
loadMore,
|
||||
}: Pick<MarketplaceRowsArgs, "registry" | "registryLoading" | "loadMore">) {
|
||||
if (!registry) return null;
|
||||
const shown = registry.entries.length;
|
||||
const total = registry.matched;
|
||||
const moreCached = total > shown;
|
||||
const moreOnNetwork = registry.hasMore;
|
||||
const canDoSomething = moreCached || moreOnNetwork;
|
||||
|
||||
// Three states:
|
||||
// 1. Loading — disabled button + spinner-ish label
|
||||
// 2. More available — primary button + count of what's loaded
|
||||
// 3. Exhausted — distinct success-tinted card so the user
|
||||
// doesn't think the button stopped responding
|
||||
if (canDoSomething) {
|
||||
const label = registryLoading
|
||||
? t("mcp.marketplaceLoading")
|
||||
: t("mcp.marketplaceMoreLabel", {
|
||||
shown,
|
||||
total: moreOnNetwork ? `${total}+` : `${total}`,
|
||||
});
|
||||
return html`<div style="padding:10px 12px;display:flex;align-items:center;gap:10px">
|
||||
<button class="btn primary" disabled=${registryLoading} onClick=${loadMore}>${label}</button>
|
||||
<span style="font-size:11px;color:var(--fg-3)">
|
||||
${moreOnNetwork ? t("mcp.marketplaceMoreHint") : t("mcp.marketplaceMoreCachedHint")}
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<div style="padding:12px;background:var(--bg-elev-2,rgba(36,143,242,0.07));border-top:1px solid var(--bd);display:flex;align-items:center;gap:8px;font-size:12px;color:var(--fg-2)">
|
||||
<span style="color:var(--c-ok)">✓</span>
|
||||
<span>${t("mcp.marketplaceExhaustedFull", { total })}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMarketplaceRows({
|
||||
registry,
|
||||
registryLoading,
|
||||
openRegistry,
|
||||
setOpenRegistry,
|
||||
loadMore,
|
||||
installedSpecs,
|
||||
}: MarketplaceRowsArgs) {
|
||||
if (!registry && registryLoading) {
|
||||
return html`<div style="color:var(--fg-3);padding:14px;font-size:12px">${t("mcp.marketplaceLoading")}</div>`;
|
||||
}
|
||||
if (!registry || registry.entries.length === 0) {
|
||||
return html`<div style="color:var(--fg-3);padding:14px;font-size:12px">${t("mcp.marketplaceNoMatches")}</div>`;
|
||||
}
|
||||
return html`
|
||||
${registry.entries.map((e) => {
|
||||
const sel = openRegistry?.name === e.name;
|
||||
const tag = t("mcp.marketplaceSourceTag", { source: e.source });
|
||||
const spec = specForEntry(e);
|
||||
const installed = spec !== null && installedSpecs.has(spec);
|
||||
const pop =
|
||||
e.popularity !== undefined
|
||||
? html` <span class="dim">· ${fmtNum(e.popularity)}</span>`
|
||||
: null;
|
||||
const icon = e.iconUrl
|
||||
? html`<img src=${e.iconUrl} alt="" style="width:16px;height:16px;border-radius:3px;margin-right:6px;vertical-align:middle;object-fit:cover" loading="lazy" referrerpolicy="no-referrer" onError=${(ev: Event) => ((ev.target as HTMLImageElement).style.display = "none")} />`
|
||||
: null;
|
||||
return html`
|
||||
<div class=${`ssl-row ${sel ? "sel" : ""}`} onClick=${() => setOpenRegistry(e)}>
|
||||
<span class="name">${icon}${e.name} <span class="pill">${tag}</span>${installed ? html` <span class="pill ok">${t("mcp.marketplaceInstalledBadge")}</span>` : null}</span>
|
||||
<span class="preview">${e.description}</span>
|
||||
<span class="meta">${pop}</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${renderLoadMoreFooter({ registry, registryLoading, loadMore })}
|
||||
`;
|
||||
}
|
||||
|
||||
interface RegistryDetailArgs {
|
||||
entry: RegistryEntryDto;
|
||||
busy: boolean;
|
||||
installedSpec: string | null;
|
||||
onInstall: () => void;
|
||||
onUninstall: (spec: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function renderRegistryDetail({
|
||||
entry,
|
||||
busy,
|
||||
installedSpec,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onClose,
|
||||
}: RegistryDetailArgs) {
|
||||
const installable = !!entry.install || entry.source === "smithery";
|
||||
const installed = installedSpec !== null;
|
||||
const specPreview = entry.install
|
||||
? `${entry.install.runtime} · ${entry.install.transport}${
|
||||
entry.install.packageId
|
||||
? ` · ${entry.install.packageId}`
|
||||
: entry.install.url
|
||||
? ` · ${entry.install.url}`
|
||||
: ""
|
||||
}${entry.install.version ? `@${entry.install.version}` : ""}`
|
||||
: "";
|
||||
const icon = entry.iconUrl
|
||||
? html`<img src=${entry.iconUrl} alt="" style="width:24px;height:24px;border-radius:4px;margin-right:8px;vertical-align:middle;object-fit:cover" loading="lazy" referrerpolicy="no-referrer" onError=${(ev: Event) => ((ev.target as HTMLImageElement).style.display = "none")} />`
|
||||
: null;
|
||||
return html`
|
||||
<div class="sessions-detail-h">
|
||||
<span class="name">${icon}${entry.name}${installed ? html` <span class="pill ok">${t("mcp.marketplaceInstalledBadge")}</span>` : null}</span>
|
||||
<span class="ws">${t("mcp.marketplaceSourceTag", { source: entry.source })}${
|
||||
entry.popularity !== undefined ? ` · ${fmtNum(entry.popularity)} uses` : ""
|
||||
}${entry.homepage ? html` · <a href=${entry.homepage} target="_blank" rel="noopener noreferrer">homepage</a>` : ""}</span>
|
||||
<span class="actions">
|
||||
${
|
||||
installed
|
||||
? html`<button
|
||||
class="btn"
|
||||
disabled=${busy}
|
||||
onClick=${() => onUninstall(installedSpec)}
|
||||
style="border-color:var(--c-err);color:var(--c-err)"
|
||||
>${t("mcp.marketplaceUninstall")}</button>`
|
||||
: html`<button class="btn primary" disabled=${busy || !installable} onClick=${onInstall}>${t("mcp.marketplaceInstall")}</button>`
|
||||
}
|
||||
<button class="btn ghost" onClick=${onClose}>${t("common.back")}</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6">${entry.description || "—"}</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
entry.install
|
||||
? html`<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">${t("mcp.spec")}</span></div>
|
||||
<div class="card-b">
|
||||
<code class="mono" style="font-size:11.5px;color:var(--fg-2);word-break:break-all;display:block">${specPreview}</code>
|
||||
${
|
||||
installedSpec
|
||||
? html`<div style="margin-top:8px;font-size:11px;color:var(--fg-3)">
|
||||
<span class="dim">on disk:</span> <code class="mono">${installedSpec}</code>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</div>`
|
||||
: entry.source === "smithery"
|
||||
? html`<div class="card" style="margin-bottom:12px">
|
||||
<div class="card-b" style="font-size:13px;line-height:1.6;color:var(--fg-3)">
|
||||
${t("mcp.marketplaceFetchOnInstall")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
entry.install?.requiredEnv?.length
|
||||
? html`<div class="card accent-brand" style="margin-bottom:12px">
|
||||
<div class="card-h"><span class="title">${t("mcp.marketplaceEnvTitle")}</span></div>
|
||||
<div class="card-b" style="font-size:13px">
|
||||
${entry.install.requiredEnv.map(
|
||||
(name) =>
|
||||
html`<div><code class="mono" style="color:var(--c-warn)">${name}</code></div>`,
|
||||
)}
|
||||
<div style="margin-top:6px;color:var(--fg-3);font-size:12px">
|
||||
${t("mcp.marketplaceEnvHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
installed
|
||||
? html`<div class="card accent-warn">
|
||||
<div class="card-b" style="font-size:12.5px;line-height:1.6">
|
||||
${t("mcp.marketplaceRestartHint")}
|
||||
</div>
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -1,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.24.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,6 +72,7 @@
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-reconciler": "^0.29.2",
|
||||
@@ -79,10 +82,13 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFileSync, appendFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname;
|
||||
const SUMMARY_PATH = join(ROOT, "coverage", "coverage-summary.json");
|
||||
|
||||
const GITHUB_STEP_SUMMARY = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (!GITHUB_STEP_SUMMARY) {
|
||||
console.log("Not running in GitHub Actions (no GITHUB_STEP_SUMMARY). Skipping.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(SUMMARY_PATH, "utf-8"));
|
||||
} catch (err) {
|
||||
console.error("Failed to read coverage summary:", err.message);
|
||||
process.exit(0); // don't fail the build if coverage report is missing
|
||||
}
|
||||
|
||||
const { total } = data;
|
||||
if (!total) {
|
||||
console.error("No 'total' key in coverage summary. Skipping.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const pct = (metric) => {
|
||||
const m = total[metric];
|
||||
if (!m) return "-";
|
||||
return `${m.pct.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const rows = [
|
||||
"| Metric | Coverage |",
|
||||
"| ------ | -------- |",
|
||||
`| Statements | ${pct("statements")} |`,
|
||||
`| Branches | ${pct("branches")} |`,
|
||||
`| Functions | ${pct("functions")} |`,
|
||||
`| Lines | ${pct("lines")} |`,
|
||||
];
|
||||
|
||||
const md = `## Coverage\n\n${rows.join("\n")}\n`;
|
||||
appendFileSync(GITHUB_STEP_SUMMARY, md + "\n");
|
||||
console.log("Coverage summary written to job summary.");
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Probes whether mutating a mid-history message destroys DeepSeek's prompt
|
||||
* cache for everything after the mutation point.
|
||||
*
|
||||
* Hypothesis: our compactInPlace() rewrites old tool results, which shifts
|
||||
* the byte offsets of every subsequent message. DeepSeek caches by exact
|
||||
* prefix, so the next request would cache-hit only up to the mutation
|
||||
* point, even though most of the conversation is unchanged.
|
||||
*
|
||||
* Run: node scripts/probe-cache.mjs
|
||||
* Reads DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from .env.testbak.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function loadDotenv(path) {
|
||||
const txt = readFileSync(path, "utf8");
|
||||
for (const line of txt.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) process.env[m[1]] ??= m[2];
|
||||
}
|
||||
}
|
||||
loadDotenv("./.env.testbak");
|
||||
|
||||
const KEY = process.env.DEEPSEEK_API_KEY;
|
||||
const BASE = process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com";
|
||||
if (!KEY) throw new Error("DEEPSEEK_API_KEY missing");
|
||||
|
||||
const filler = (label, n) =>
|
||||
Array.from({ length: n }, (_, i) => `${label} line ${i}: lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`).join("\n");
|
||||
|
||||
const SYS = "You are a terse echo bot. Reply with a single short sentence.";
|
||||
const MSG_A_FULL = `Long context block A. Detail follows:\n${filler("A", 80)}`;
|
||||
const MSG_A_TRUNCATED = `Long context block A. Detail follows:\n${filler("A", 8)}\n[truncated]`;
|
||||
const MSG_B = `Block B reference: ${filler("B", 30)}`;
|
||||
const MSG_C = `Block C reference: ${filler("C", 30)}`;
|
||||
|
||||
async function call(label, messages) {
|
||||
const t0 = Date.now();
|
||||
const res = await fetch(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
messages,
|
||||
temperature: 0,
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
const ms = Date.now() - t0;
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${label} http ${res.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
const j = await res.json();
|
||||
const u = j.usage ?? {};
|
||||
const hit = u.prompt_cache_hit_tokens ?? 0;
|
||||
const miss = u.prompt_cache_miss_tokens ?? 0;
|
||||
const prompt = u.prompt_tokens ?? 0;
|
||||
const ratio = hit + miss > 0 ? (hit / (hit + miss)) * 100 : 0;
|
||||
console.log(
|
||||
`[${label}] prompt=${prompt} hit=${hit} miss=${miss} hit%=${ratio.toFixed(1)} ${ms}ms`,
|
||||
);
|
||||
return { hit, miss, prompt };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
console.log("=== Phase 1: warm cache with full conversation ===");
|
||||
const warm = [
|
||||
{ role: "system", content: SYS },
|
||||
{ role: "user", content: MSG_A_FULL },
|
||||
{ role: "assistant", content: "ack A" },
|
||||
{ role: "user", content: MSG_B },
|
||||
{ role: "assistant", content: "ack B" },
|
||||
{ role: "user", content: MSG_C },
|
||||
{ role: "assistant", content: "ack C" },
|
||||
{ role: "user", content: "say hi" },
|
||||
];
|
||||
await call("warm-1", warm);
|
||||
await sleep(1500);
|
||||
await call("warm-2", warm);
|
||||
|
||||
console.log("\n=== Phase 2: append a new turn (cache should hit on prefix) ===");
|
||||
const appended = [
|
||||
...warm,
|
||||
{ role: "assistant", content: "hi" },
|
||||
{ role: "user", content: "say bye" },
|
||||
];
|
||||
await sleep(1500);
|
||||
const appendResult = await call("append", appended);
|
||||
|
||||
console.log("\n=== Phase 3: mutate MSG_A in place + append same new turn ===");
|
||||
const mutated = [
|
||||
{ role: "system", content: SYS },
|
||||
{ role: "user", content: MSG_A_TRUNCATED },
|
||||
{ role: "assistant", content: "ack A" },
|
||||
{ role: "user", content: MSG_B },
|
||||
{ role: "assistant", content: "ack B" },
|
||||
{ role: "user", content: MSG_C },
|
||||
{ role: "assistant", content: "ack C" },
|
||||
{ role: "user", content: "say hi" },
|
||||
{ role: "assistant", content: "hi" },
|
||||
{ role: "user", content: "say bye" },
|
||||
];
|
||||
await sleep(1500);
|
||||
const mutateResult = await call("mutate", mutated);
|
||||
|
||||
console.log("\n=== Result (Phases 1-3) ===");
|
||||
const lostHit = appendResult.hit - mutateResult.hit;
|
||||
console.log(
|
||||
`append: hit=${appendResult.hit}/${appendResult.prompt} mutate: hit=${mutateResult.hit}/${mutateResult.prompt}`,
|
||||
);
|
||||
console.log(`cache tokens lost to in-place mutation: ${lostHit}`);
|
||||
if (lostHit > 100) {
|
||||
console.log("VERDICT: in-place mutation destroys cache. Hypothesis confirmed.");
|
||||
} else {
|
||||
console.log("VERDICT: cache survived mutation. Hypothesis rejected.");
|
||||
}
|
||||
|
||||
console.log("\n=== Phase 4: append-only across many turns (the new code's behavior) ===");
|
||||
const session = [
|
||||
{ role: "system", content: SYS },
|
||||
{ role: "user", content: MSG_A_FULL },
|
||||
{ role: "assistant", content: "ack A" },
|
||||
{ role: "user", content: MSG_B },
|
||||
{ role: "assistant", content: "ack B" },
|
||||
];
|
||||
const ratios = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
session.push({ role: "user", content: `turn ${i}: ${filler(`Q${i}`, 10)}` });
|
||||
await sleep(1200);
|
||||
const r = await call(`turn-${i}`, session);
|
||||
session.push({ role: "assistant", content: `reply ${i}` });
|
||||
const total = r.hit + r.miss;
|
||||
ratios.push(total > 0 ? (r.hit / total) * 100 : 0);
|
||||
}
|
||||
const avg = ratios.reduce((a, b) => a + b, 0) / ratios.length;
|
||||
console.log(`\nappend-only cache hit % across 5 turns: ${ratios.map((x) => x.toFixed(1)).join(", ")}`);
|
||||
console.log(`average: ${avg.toFixed(1)}%`);
|
||||
if (avg > 80) {
|
||||
console.log("VERDICT: append-only keeps cache warm across turns. New strategy validated.");
|
||||
} else {
|
||||
console.log("VERDICT: cache hit lower than expected — investigate.");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Long-session probe — drives CacheFirstLoop through 20 real turns
|
||||
* with oversized tool results (each ~4k tokens, the size that USED to
|
||||
* trigger the old turn-end auto-compaction every turn).
|
||||
*
|
||||
* Reports per-turn: prompt size, cache hit %, miss tokens, USD cost.
|
||||
* Surfaces: cache trajectory, cost shape, anything degrading over time.
|
||||
*
|
||||
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-long-session.mts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { ImmutablePrefix } from "../src/memory/runtime.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS } from "../src/telemetry/stats.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
|
||||
// Force a small ctx window so the 50% fold threshold trips in a few
|
||||
// turns instead of needing 200+ turns at the real 1M cap. Same model
|
||||
// id, real API call, just the local gauge is shrunk.
|
||||
DEEPSEEK_CONTEXT_TOKENS["deepseek-chat"] = 50_000;
|
||||
|
||||
function loadDotenv(path: string) {
|
||||
const txt = readFileSync(path, "utf8");
|
||||
for (const line of txt.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
loadDotenv("./.env.testbak");
|
||||
|
||||
const PRICE_HIT_PER_M = 0.07;
|
||||
const PRICE_MISS_PER_M = 0.27;
|
||||
const PRICE_COMPLETION_PER_M = 1.1;
|
||||
|
||||
const docLine = (i: number, sec: string) =>
|
||||
`[${sec}#${i}] section ${sec} entry ${i}: requirement traces to constraint ${(i % 7) + 1}, status ${i % 3 === 0 ? "open" : "closed"}, owner team-${(i % 5) + 1}, last touched 2026-04-${(i % 28) + 1}.`;
|
||||
|
||||
async function main() {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({
|
||||
name: "read_doc",
|
||||
description: "Read a section of a project document.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { section: { type: "string" } },
|
||||
required: ["section"],
|
||||
},
|
||||
fn: async (args: Record<string, unknown>) => {
|
||||
const sec = String(args.section ?? "default");
|
||||
const lines = Array.from({ length: 65 }, (_, i) => docLine(i, sec));
|
||||
return lines.join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
const client = new DeepSeekClient();
|
||||
const model = "deepseek-chat";
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({
|
||||
system:
|
||||
"You are a documentation triage agent. For each turn, call read_doc with the section the user asks about, then reply with one short sentence summarizing what you found.",
|
||||
toolSpecs: reg.specs(),
|
||||
}),
|
||||
tools: reg,
|
||||
stream: false,
|
||||
model,
|
||||
maxToolIters: 4,
|
||||
});
|
||||
|
||||
let mutations = 0;
|
||||
let folds = 0;
|
||||
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
|
||||
loop.log.compactInPlace = (...args) => {
|
||||
mutations++;
|
||||
return origCompactInPlace(...args);
|
||||
};
|
||||
|
||||
const sections = [
|
||||
"auth",
|
||||
"billing",
|
||||
"telemetry",
|
||||
"rate-limit",
|
||||
"webhooks",
|
||||
"search",
|
||||
"indexing",
|
||||
"permissions",
|
||||
"audit",
|
||||
"exports",
|
||||
"imports",
|
||||
"cdn",
|
||||
"analytics",
|
||||
"rbac",
|
||||
"sso",
|
||||
"scheduler",
|
||||
"workflows",
|
||||
"notifications",
|
||||
"reports",
|
||||
"api-v2",
|
||||
];
|
||||
|
||||
console.log("turn | prompt | hit | miss | hit% | $/turn | $cum");
|
||||
console.log("-----+----------+---------+---------+------+----------+--------");
|
||||
|
||||
let cumCost = 0;
|
||||
let forceSummaryHit = false;
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const t0 = Date.now();
|
||||
let usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
promptCacheHitTokens: number;
|
||||
promptCacheMissTokens: number;
|
||||
} | null = null;
|
||||
let warning = "";
|
||||
for await (const ev of loop.step(`Read the "${sections[i]}" section.`)) {
|
||||
if (ev.role === "assistant_final" && ev.stats?.usage) {
|
||||
usage = ev.stats.usage as typeof usage;
|
||||
}
|
||||
if (ev.role === "warning" && ev.content) {
|
||||
warning = ev.content;
|
||||
if (/folded \d+ messages/.test(ev.content)) folds++;
|
||||
}
|
||||
if (ev.forcedSummary) forceSummaryHit = true;
|
||||
}
|
||||
const ms = Date.now() - t0;
|
||||
if (!usage) {
|
||||
console.log(
|
||||
`${String(i).padStart(3)} | (no usage) -- ${warning ? `warning: ${warning}` : ""}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
|
||||
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
|
||||
const cost =
|
||||
(usage.promptCacheHitTokens * PRICE_HIT_PER_M +
|
||||
usage.promptCacheMissTokens * PRICE_MISS_PER_M +
|
||||
usage.completionTokens * PRICE_COMPLETION_PER_M) /
|
||||
1_000_000;
|
||||
cumCost += cost;
|
||||
console.log(
|
||||
`${String(i).padStart(3)} | ${String(usage.promptTokens).padStart(7)} | ${String(usage.promptCacheHitTokens).padStart(7)} | ${String(usage.promptCacheMissTokens).padStart(7)} | ${ratio.toFixed(1).padStart(4)} | $${cost.toFixed(5)} | $${cumCost.toFixed(4)} ${ms}ms${warning ? ` (${warning.slice(0, 60)}…)` : ""}`,
|
||||
);
|
||||
if (forceSummaryHit) {
|
||||
console.log(
|
||||
`\n>> force-summary triggered at turn ${i} (${ratio.toFixed(1)}% cache hit, ${usage.promptTokens} tokens)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\ntotal log.compactInPlace() calls: ${mutations} (expected: ${folds} folds)`);
|
||||
console.log(`total cost across session: $${cumCost.toFixed(4)}`);
|
||||
|
||||
if (mutations !== folds) {
|
||||
console.log(`FAIL: ${mutations} mutations but only ${folds} fold events — unexpected mutation`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nVERDICT: ${folds} fold(s) fired, no other mutations.`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* End-to-end cache probe — drives CacheFirstLoop through real turns
|
||||
* against the live DeepSeek API and reports cache hit % per turn.
|
||||
*
|
||||
* The point: validate that the post-PR code (no auto-compaction)
|
||||
* actually sustains high cache hit on a long-ish session, not just
|
||||
* that the API-level append-vs-mutate primitive behaves as expected.
|
||||
*
|
||||
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-loop-cache.mts
|
||||
* Reads DEEPSEEK_API_KEY from .env.testbak.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { ImmutablePrefix } from "../src/memory/runtime.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
|
||||
function loadDotenv(path: string) {
|
||||
const txt = readFileSync(path, "utf8");
|
||||
for (const line of txt.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
loadDotenv("./.env.testbak");
|
||||
|
||||
const filler = (label: string, n: number): string =>
|
||||
Array.from(
|
||||
{ length: n },
|
||||
(_, i) =>
|
||||
`${label} line ${i}: lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||
).join("\n");
|
||||
|
||||
async function main() {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({
|
||||
name: "echo",
|
||||
description: "echo the input back",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { msg: { type: "string" } },
|
||||
required: ["msg"],
|
||||
},
|
||||
fn: async (args: Record<string, unknown>) => `echoed: ${String(args.msg ?? "")}`,
|
||||
});
|
||||
|
||||
const client = new DeepSeekClient();
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({
|
||||
system:
|
||||
"You are a terse echo bot. Reply with one short sentence. Do not call any tools unless explicitly asked.",
|
||||
toolSpecs: reg.specs(),
|
||||
}),
|
||||
tools: reg,
|
||||
stream: false,
|
||||
maxToolIters: 4,
|
||||
});
|
||||
|
||||
// Pre-seed log with a moderate prior conversation (~6k tokens of
|
||||
// user/assistant turns) so the cache has something substantial to
|
||||
// hit across subsequent turns.
|
||||
loop.log.append({ role: "user", content: `prior context: ${filler("ctx", 60)}` });
|
||||
loop.log.append({ role: "assistant", content: "noted." });
|
||||
loop.log.append({ role: "user", content: `more context: ${filler("more", 40)}` });
|
||||
loop.log.append({ role: "assistant", content: "noted." });
|
||||
|
||||
const ratios: number[] = [];
|
||||
let mutations = 0;
|
||||
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
|
||||
loop.log.compactInPlace = (...args: Parameters<typeof origCompactInPlace>) => {
|
||||
mutations++;
|
||||
return origCompactInPlace(...args);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let usage: { promptTokens: number; promptCacheHitTokens: number; promptCacheMissTokens: number } | null = null;
|
||||
for await (const ev of loop.step(`Turn ${i}: just say "ok ${i}".`)) {
|
||||
if (ev.role === "assistant_final" && ev.stats?.usage) {
|
||||
usage = ev.stats.usage as typeof usage;
|
||||
}
|
||||
}
|
||||
if (!usage) {
|
||||
console.log(`turn-${i}: no usage captured`);
|
||||
ratios.push(0);
|
||||
continue;
|
||||
}
|
||||
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
|
||||
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
|
||||
console.log(
|
||||
`turn-${i}: prompt=${usage.promptTokens} hit=${usage.promptCacheHitTokens} miss=${usage.promptCacheMissTokens} hit%=${ratio.toFixed(1)}`,
|
||||
);
|
||||
ratios.push(ratio);
|
||||
}
|
||||
|
||||
console.log(`\ntotal log.compactInPlace() calls: ${mutations}`);
|
||||
console.log(`cache hit % per turn: ${ratios.map((x) => x.toFixed(1)).join(", ")}`);
|
||||
|
||||
const warmRatios = ratios.slice(1);
|
||||
const avgWarm = warmRatios.reduce((a, b) => a + b, 0) / warmRatios.length;
|
||||
console.log(`warm-turn average (excluding cold start): ${avgWarm.toFixed(1)}%`);
|
||||
|
||||
if (mutations > 0) {
|
||||
console.log(`\nFAIL: log was mutated ${mutations} time(s) — append-only invariant broken`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (avgWarm < 80) {
|
||||
console.log(`\nFAIL: warm-turn average ${avgWarm.toFixed(1)}% below 80% threshold`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nPASS: append-only sustained, cache hit avg ${avgWarm.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -315,8 +315,10 @@ export function expandAtMentions(
|
||||
for (const match of text.matchAll(AT_MENTION_PATTERN)) {
|
||||
const rawPath = match[1] ?? "";
|
||||
// Strip trailing dot (sentence terminator): `@foo.ts.` → `@foo.ts`.
|
||||
// Keep internal dots intact.
|
||||
const cleaned = rawPath.replace(/\.+$/, "");
|
||||
// Keep internal dots intact. Manual loop instead of `/\.+$/` — the
|
||||
// regex is O(n²) on dot-heavy non-matches per CodeQL js/polynomial-redos.
|
||||
let cleaned = rawPath;
|
||||
while (cleaned.endsWith(".")) cleaned = cleaned.slice(0, -1);
|
||||
if (!cleaned) continue;
|
||||
const token = `@${cleaned}`;
|
||||
if (seen.has(token)) continue;
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import {
|
||||
CharPool,
|
||||
type Handle,
|
||||
HyperlinkPool,
|
||||
StylePool,
|
||||
inkCompat,
|
||||
mount,
|
||||
useKeystroke,
|
||||
} from "../../renderer/index.js";
|
||||
|
||||
const BRAND = "#79c0ff";
|
||||
const FAINT = "#6e7681";
|
||||
const ACCENT = "#d2a8ff";
|
||||
const BOX_INNER_WIDTH = 35;
|
||||
|
||||
const HINTS = ["/help", "/init", "/memory", "/cost"] as const;
|
||||
|
||||
function centerInside(text: string, pad: number): string {
|
||||
if (text.length >= pad) return text;
|
||||
const left = Math.floor((pad - text.length) / 2);
|
||||
const right = pad - text.length - left;
|
||||
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
|
||||
}
|
||||
|
||||
function Banner({ onExit }: { onExit: () => void }): React.ReactElement {
|
||||
const { stdout } = inkCompat.useStdout();
|
||||
const cols = stdout.columns;
|
||||
const boxWidth = BOX_INNER_WIDTH + 2;
|
||||
const boxIndent = Math.max(2, Math.floor((cols - boxWidth) / 2));
|
||||
const pad = " ".repeat(boxIndent);
|
||||
const empty = `║${" ".repeat(BOX_INNER_WIDTH)}║`;
|
||||
const row = (s: string) => `${pad}${s}`;
|
||||
|
||||
useKeystroke((k) => {
|
||||
if (k.escape) onExit();
|
||||
});
|
||||
|
||||
return (
|
||||
<inkCompat.Box flexDirection="column" marginY={1}>
|
||||
<inkCompat.Text color={BRAND}>{row(`╔${"═".repeat(BOX_INNER_WIDTH)}╗`)}</inkCompat.Text>
|
||||
<inkCompat.Text color={BRAND}>{row(empty)}</inkCompat.Text>
|
||||
<inkCompat.Text color={BRAND} bold>
|
||||
{row(`║${centerInside("◈ REASONIX", BOX_INNER_WIDTH)}║`)}
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Text color={BRAND}>{row(empty)}</inkCompat.Text>
|
||||
<inkCompat.Text color={BRAND}>
|
||||
{row(`║${centerInside("cell-diff renderer demo", BOX_INNER_WIDTH)}║`)}
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Text color={BRAND}>
|
||||
{row(`║${centerInside("press ESC to exit", BOX_INNER_WIDTH)}║`)}
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Text color={BRAND}>{row(empty)}</inkCompat.Text>
|
||||
<inkCompat.Text color={BRAND}>{row(`╚${"═".repeat(BOX_INNER_WIDTH)}╝`)}</inkCompat.Text>
|
||||
|
||||
<inkCompat.Box marginTop={1} flexDirection="row" justifyContent="center">
|
||||
{HINTS.map((cmd, i) => (
|
||||
<React.Fragment key={cmd}>
|
||||
<inkCompat.Text color={FAINT}>{cmd}</inkCompat.Text>
|
||||
{i < HINTS.length - 1 ? (
|
||||
<inkCompat.Text color={ACCENT}>{" · "}</inkCompat.Text>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</inkCompat.Box>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface BannerDemoOptions {
|
||||
readonly stdout?: NodeJS.WriteStream;
|
||||
readonly stdin?: NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
export async function runBannerDemo(opts: BannerDemoOptions = {}): Promise<void> {
|
||||
const stdout = opts.stdout ?? process.stdout;
|
||||
const stdin = opts.stdin ?? process.stdin;
|
||||
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
console.error("banner-demo requires an interactive TTY.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pools = {
|
||||
char: new CharPool(),
|
||||
style: new StylePool(),
|
||||
hyperlink: new HyperlinkPool(),
|
||||
};
|
||||
|
||||
let resolveExit: () => void = () => {};
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const handle: Handle = mount(<Banner onExit={() => resolveExit()} />, {
|
||||
viewportWidth: stdout.columns ?? 80,
|
||||
viewportHeight: stdout.rows ?? 24,
|
||||
pools,
|
||||
write: (bytes) => stdout.write(bytes),
|
||||
stdin,
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
handle.resize(stdout.columns ?? 80, stdout.rows ?? 24);
|
||||
};
|
||||
stdout.on("resize", onResize);
|
||||
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
stdout.off("resize", onResize);
|
||||
handle.destroy();
|
||||
stdin.pause();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -35,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;
|
||||
@@ -96,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({
|
||||
@@ -105,6 +322,7 @@ function Root({
|
||||
mcpServers,
|
||||
progressSink,
|
||||
showPicker,
|
||||
mcpRuntime,
|
||||
...appProps
|
||||
}: RootProps) {
|
||||
const [key, setKey] = useState<string | undefined>(initialKey);
|
||||
@@ -176,6 +394,7 @@ function Root({
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
mcpRuntime={mcpRuntime}
|
||||
progressSink={progressSink}
|
||||
codeMode={appProps.codeMode}
|
||||
noDashboard={appProps.noDashboard}
|
||||
@@ -190,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
|
||||
@@ -204,115 +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";
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
const spec = parseMcpSpec(raw);
|
||||
label = spec.name ?? "anon";
|
||||
if (spec.name && disabledNames.has(spec.name)) {
|
||||
process.stderr.write(`${formatMcpLifecycleEvent({ state: "disabled", name: label })}\n`);
|
||||
continue;
|
||||
}
|
||||
process.stderr.write(`${formatMcpLifecycleEvent({ state: "handshake", name: label })}\n`);
|
||||
const t0 = Date.now();
|
||||
const prefix = spec.name
|
||||
? `${spec.name}_`
|
||||
: requestedSpecs.length === 1 && opts.mcpPrefix
|
||||
? opts.mcpPrefix
|
||||
: "";
|
||||
const transport: McpTransport =
|
||||
spec.transport === "sse"
|
||||
? new SseTransport({ url: spec.url })
|
||||
: spec.transport === "streamable-http"
|
||||
? new StreamableHttpTransport({ url: spec.url })
|
||||
: new StdioTransport({ command: spec.command, args: spec.args });
|
||||
mcp = new McpClient({ transport });
|
||||
await mcp.initialize();
|
||||
// Host indirection lets `/mcp reconnect` swap the underlying client
|
||||
// without re-bridging the registered tools (closures resolve via
|
||||
// host.client at call time).
|
||||
const host: McpClientHost = { client: mcp };
|
||||
const bridge = await bridgeMcpTools(mcp, {
|
||||
registry: tools,
|
||||
namePrefix: prefix,
|
||||
serverName: label,
|
||||
host,
|
||||
onProgress: (info) => progressSink.current?.(info),
|
||||
onSlow: (info) =>
|
||||
process.stderr.write(
|
||||
`${formatMcpSlowToast({ name: info.serverName, p95Ms: info.p95Ms, sampleSize: info.sampleSize })}\n`,
|
||||
),
|
||||
});
|
||||
// Inspect collects resources + prompts once so `/mcp` can render them
|
||||
// synchronously. Servers that don't support these fall through as
|
||||
// `{supported: false}` instead of throwing.
|
||||
let report: InspectionReport;
|
||||
try {
|
||||
report = await inspectMcpServer(mcp);
|
||||
} catch {
|
||||
report = {
|
||||
protocolVersion: mcp.protocolVersion,
|
||||
serverInfo: mcp.serverInfo,
|
||||
capabilities: mcp.serverCapabilities ?? {},
|
||||
tools: { supported: true, items: [] },
|
||||
resources: { supported: false, reason: "inspect failed" },
|
||||
prompts: { supported: false, reason: "inspect failed" },
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
const ms = Date.now() - t0;
|
||||
const resourceCount = report.resources.supported ? report.resources.items.length : 0;
|
||||
const promptCount = report.prompts.supported ? report.prompts.items.length : 0;
|
||||
process.stderr.write(
|
||||
`${formatMcpLifecycleEvent({
|
||||
state: "connected",
|
||||
name: label,
|
||||
tools: bridge.registeredNames.length,
|
||||
resources: resourceCount,
|
||||
prompts: promptCount,
|
||||
ms,
|
||||
})}\n`,
|
||||
);
|
||||
clients.push(mcp);
|
||||
successfulSpecs.push(raw);
|
||||
mcpServers.push(
|
||||
buildMcpServerSummary({
|
||||
label,
|
||||
spec: raw,
|
||||
toolCount: bridge.registeredNames.length,
|
||||
report,
|
||||
host,
|
||||
bridgeEnv: bridge.env,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
// Per-server failure is non-fatal: one broken server shouldn't
|
||||
// kill a chat that has working servers configured. Recover by
|
||||
// recording the failure and continuing; user fixes via `reasonix
|
||||
// setup` without losing their other servers.
|
||||
await mcp?.close().catch(() => undefined);
|
||||
const reason = (err as Error).message;
|
||||
failedSpecs.push({ spec: raw, reason });
|
||||
process.stderr.write(
|
||||
`${formatMcpLifecycleEvent({ state: "failed", name: label, reason })}\n → run \`reasonix setup\` to remove this entry, or fix the underlying issue (missing npm package, network, etc.).\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// If every requested server failed AND no seed registry was
|
||||
// provided, drop the empty registry so the loop still runs as a
|
||||
// bare chat instead of advertising zero tools. If the caller
|
||||
// passed seedTools we keep the registry — the seed tools are
|
||||
// still there and usable.
|
||||
if (successfulSpecs.length === 0 && !opts.seedTools) {
|
||||
tools = undefined;
|
||||
}
|
||||
const runtime = createMcpRuntime({
|
||||
getTools: () => tools,
|
||||
getMcpPrefix: () => opts.mcpPrefix,
|
||||
getRequestedCount: () => requestedSpecs.length,
|
||||
progressSink,
|
||||
});
|
||||
|
||||
const failedSpecs: Array<{ spec: string; reason: string }> = [];
|
||||
for (const raw of requestedSpecs) {
|
||||
const result = await runtime.addSpec(raw);
|
||||
if (!result.ok) failedSpecs.push({ spec: raw, reason: result.reason });
|
||||
}
|
||||
const mcpSpecs = successfulSpecs;
|
||||
if (runtime.size() === 0 && !opts.seedTools) {
|
||||
tools = undefined;
|
||||
}
|
||||
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
|
||||
@@ -367,6 +492,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
mcpRuntime={runtime}
|
||||
progressSink={progressSink}
|
||||
showPicker={showPicker}
|
||||
{...opts}
|
||||
@@ -378,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":
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
/** Reproduces the streaming-card + ChoiceConfirm-style modal scenario the user
|
||||
* reports flickers on. Loops continuously: streaming card grows for ~1.2s,
|
||||
* modal pops up below, streaming continues for ~1.5s, modal stays for ~3s,
|
||||
* everything tears down, sleep, repeat. Watch the live region — any visible
|
||||
* blank rows / repaint flashes during the modal-mount transition is the bug.
|
||||
*
|
||||
* Use --real-modal to mirror the actual chat flow more closely:
|
||||
* - wrap the modal in ViewportBudgetProvider + useReserveRows
|
||||
* - use the real ApprovalCard (with dynamic-width horizontal rule)
|
||||
* Without the flag, the modal is hand-rolled — equivalent layout, no budget. */
|
||||
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
CharPool,
|
||||
type Handle,
|
||||
HyperlinkPool,
|
||||
StylePool,
|
||||
inkCompat,
|
||||
mount,
|
||||
useKeystroke,
|
||||
} from "../../renderer/index.js";
|
||||
import { ApprovalCard } from "../ui/cards/ApprovalCard.js";
|
||||
import { ViewportBudgetProvider, useReserveRows } from "../ui/layout/viewport-budget.js";
|
||||
|
||||
const BRAND = "#79c0ff";
|
||||
const FAINT = "#6e7681";
|
||||
const ACCENT = "#d2a8ff";
|
||||
const OK = "#7ee787";
|
||||
const META = "#8b949e";
|
||||
|
||||
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
||||
|
||||
interface FlickerShellProps {
|
||||
readonly onExit: () => void;
|
||||
readonly realModal: boolean;
|
||||
}
|
||||
|
||||
function Header({ phase }: { phase: string }): React.ReactElement {
|
||||
return (
|
||||
<inkCompat.Box flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={BRAND} bold>
|
||||
◈ Reasonix
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Text color={FAINT}>flicker-demo · phase:</inkCompat.Text>
|
||||
<inkCompat.Text color={ACCENT} bold>
|
||||
{phase}
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Text color={FAINT}>· Esc to exit</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingCard({
|
||||
text,
|
||||
done,
|
||||
frame,
|
||||
}: {
|
||||
text: string;
|
||||
done: boolean;
|
||||
frame: number;
|
||||
}): React.ReactElement {
|
||||
const lines = text.length > 0 ? text.split("\n") : [""];
|
||||
const visible = lines.slice(-4);
|
||||
const glyph = done ? "‹" : "▸";
|
||||
const head = done ? "reply" : "writing…";
|
||||
const tone = done ? OK : BRAND;
|
||||
return (
|
||||
<inkCompat.Box flexDirection="column" marginTop={1}>
|
||||
<inkCompat.Box flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={tone}>{glyph}</inkCompat.Text>
|
||||
<inkCompat.Text color={tone} bold>
|
||||
{head}
|
||||
</inkCompat.Text>
|
||||
{!done ? (
|
||||
<inkCompat.Text color={tone}>{SPINNER[frame % SPINNER.length] ?? "·"}</inkCompat.Text>
|
||||
) : null}
|
||||
</inkCompat.Box>
|
||||
{visible.map((line, i) => (
|
||||
<inkCompat.Box key={`s-${i}-${line.length}`} paddingLeft={2}>
|
||||
<inkCompat.Text>{line || " "}</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
))}
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
const OPTIONS = [
|
||||
"1 · keep current approach",
|
||||
"2 · rewrite with the alternative",
|
||||
"3 · ask me a clarifying question first",
|
||||
"Let me type my own answer",
|
||||
"Cancel — drop the question",
|
||||
] as const;
|
||||
|
||||
function ChoiceModalInline(): React.ReactElement {
|
||||
return (
|
||||
<inkCompat.Box flexDirection="column" marginTop={1}>
|
||||
<inkCompat.Box flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={ACCENT} bold>
|
||||
?
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Text color={ACCENT} bold>
|
||||
which path should I take?
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Text color={FAINT}>· awaiting</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
<inkCompat.Box flexDirection="column" paddingLeft={2}>
|
||||
{OPTIONS.map((opt, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static option list, never reordered
|
||||
<inkCompat.Box key={`o-${i}`} flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={i === 0 ? BRAND : META}>{i === 0 ? "▸" : " "}</inkCompat.Text>
|
||||
<inkCompat.Text color={i === 0 ? BRAND : META} bold={i === 0}>
|
||||
{opt}
|
||||
</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
))}
|
||||
<inkCompat.Box marginTop={1}>
|
||||
<inkCompat.Text color={FAINT}>{"─".repeat(40)}</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
<inkCompat.Text color={FAINT}>↑↓ pick · ⏎ confirm · esc cancel</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirrors the real chat flow: wraps in ApprovalCard (dynamic rule width) +
|
||||
* claims budget via useReserveRows. Lets us see whether the budget
|
||||
* bookkeeping or the dynamic rule width is responsible for the flicker. */
|
||||
function ChoiceModalReal(): React.ReactElement {
|
||||
useReserveRows("modal", { min: 6, max: 12 });
|
||||
return (
|
||||
<ApprovalCard tone="info" title="which path should I take?" metaRight="awaiting">
|
||||
{OPTIONS.map((opt, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static option list, never reordered
|
||||
<inkCompat.Box key={`r-${i}`} flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={i === 0 ? BRAND : META}>{i === 0 ? "▸" : " "}</inkCompat.Text>
|
||||
<inkCompat.Text color={i === 0 ? BRAND : META} bold={i === 0}>
|
||||
{opt}
|
||||
</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
))}
|
||||
</ApprovalCard>
|
||||
);
|
||||
}
|
||||
|
||||
const STREAM_TEXT = `analyzing the failing test on src/loop.test.ts
|
||||
the assertion on line 42 expects the parser to drop the trailing tool-call marker
|
||||
but the new tokenizer keeps it; two paths forward — patch the tokenizer's strip
|
||||
step, or update the expectation. The trade-off is whether downstream consumers
|
||||
rely on the marker to detect partial tool-call output.`;
|
||||
|
||||
function FlickerShell({ onExit, realModal }: FlickerShellProps): React.ReactElement {
|
||||
const [streamText, setStreamText] = useState("");
|
||||
const [streamDone, setStreamDone] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [frame, setFrame] = useState(0);
|
||||
const [phase, setPhase] = useState("idle");
|
||||
|
||||
useKeystroke((k) => {
|
||||
if (k.escape) onExit();
|
||||
});
|
||||
|
||||
// Spinner ticker — runs while streaming.
|
||||
useEffect(() => {
|
||||
if (streamDone) return;
|
||||
const id = setInterval(() => setFrame((f) => f + 1), 80);
|
||||
return () => clearInterval(id);
|
||||
}, [streamDone]);
|
||||
|
||||
// Main loop — endlessly cycles through the test scenario.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), ms);
|
||||
});
|
||||
|
||||
const cycle = async (): Promise<void> => {
|
||||
while (!cancelled) {
|
||||
// Reset
|
||||
setStreamText("");
|
||||
setStreamDone(false);
|
||||
setShowModal(false);
|
||||
setPhase("streaming");
|
||||
await sleep(400);
|
||||
if (cancelled) return;
|
||||
|
||||
// Stream chunks for ~1.2s before the modal appears
|
||||
const chunks = STREAM_TEXT.split(" ");
|
||||
let revealed = "";
|
||||
for (let i = 0; i < chunks.length / 2; i++) {
|
||||
if (cancelled) return;
|
||||
revealed += `${chunks[i]} `;
|
||||
setStreamText(revealed);
|
||||
await sleep(80);
|
||||
}
|
||||
|
||||
// Modal appears WHILE streaming continues — this is the flicker scenario.
|
||||
setPhase("streaming + modal");
|
||||
setShowModal(true);
|
||||
for (let i = Math.floor(chunks.length / 2); i < chunks.length; i++) {
|
||||
if (cancelled) return;
|
||||
revealed += `${chunks[i]} `;
|
||||
setStreamText(revealed);
|
||||
await sleep(80);
|
||||
}
|
||||
|
||||
// Streaming finishes, modal stays
|
||||
setStreamDone(true);
|
||||
setPhase("done + modal");
|
||||
await sleep(2500);
|
||||
if (cancelled) return;
|
||||
|
||||
// Tear down
|
||||
setShowModal(false);
|
||||
setPhase("idle");
|
||||
await sleep(800);
|
||||
}
|
||||
};
|
||||
|
||||
void cycle();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<inkCompat.Box flexDirection="column">
|
||||
<Header phase={`${phase}${realModal ? " (real)" : ""}`} />
|
||||
{streamText.length > 0 ? (
|
||||
<StreamingCard text={streamText.trim()} done={streamDone} frame={frame} />
|
||||
) : null}
|
||||
{showModal ? realModal ? <ChoiceModalReal /> : <ChoiceModalInline /> : null}
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FlickerDemoOptions {
|
||||
readonly stdout?: NodeJS.WriteStream;
|
||||
readonly stdin?: NodeJS.ReadStream;
|
||||
/** When true, wraps the modal in the real ApprovalCard + useReserveRows
|
||||
* budget claim — same primitives the live chat ChoiceConfirm uses. */
|
||||
readonly realModal?: boolean;
|
||||
}
|
||||
|
||||
export async function runFlickerDemo(opts: FlickerDemoOptions = {}): Promise<void> {
|
||||
const stdout = opts.stdout ?? process.stdout;
|
||||
const stdin = opts.stdin ?? process.stdin;
|
||||
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
process.stderr.write("flicker-demo requires an interactive TTY.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pools = {
|
||||
char: new CharPool(),
|
||||
style: new StylePool(),
|
||||
hyperlink: new HyperlinkPool(),
|
||||
};
|
||||
|
||||
let resolveExit: () => void = () => {};
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const realModal = !!opts.realModal;
|
||||
const tree = realModal ? (
|
||||
<ViewportBudgetProvider>
|
||||
<FlickerShell onExit={() => resolveExit()} realModal={true} />
|
||||
</ViewportBudgetProvider>
|
||||
) : (
|
||||
<FlickerShell onExit={() => resolveExit()} realModal={false} />
|
||||
);
|
||||
|
||||
const handle: Handle = mount(tree, {
|
||||
viewportWidth: stdout.columns ?? 80,
|
||||
viewportHeight: stdout.rows ?? 24,
|
||||
pools,
|
||||
write: (bytes) => stdout.write(bytes),
|
||||
stdin,
|
||||
stdout,
|
||||
onExit: () => resolveExit(),
|
||||
});
|
||||
|
||||
const onResize = () => handle.resize(stdout.columns ?? 80, stdout.rows ?? 24);
|
||||
stdout.on("resize", onResize);
|
||||
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
stdout.off("resize", onResize);
|
||||
handle.destroy();
|
||||
stdin.pause();
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CharPool,
|
||||
type Handle,
|
||||
HyperlinkPool,
|
||||
StylePool,
|
||||
inkCompat,
|
||||
mount,
|
||||
useKeystroke,
|
||||
} from "../../renderer/index.js";
|
||||
|
||||
const BRAND = "#79c0ff";
|
||||
const FAINT = "#6e7681";
|
||||
const ACCENT = "#d2a8ff";
|
||||
const OK = "#7ee787";
|
||||
const ERR = "#ff8b81";
|
||||
const META = "#8b949e";
|
||||
|
||||
interface HistoryEntry {
|
||||
readonly id: number;
|
||||
readonly role: "user" | "echo" | "info" | "error";
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
||||
const SPINNER_TICK_MS = 80;
|
||||
const TOKEN_TICK_MS = 33;
|
||||
const CHARS_PER_TICK = 3;
|
||||
|
||||
function buildResponse(userText: string): string {
|
||||
return `you said: ${userText}. Streamed cell-by-cell — only the new chars get patched.`;
|
||||
}
|
||||
|
||||
interface SlashCommand {
|
||||
readonly name: string;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
const SLASH_COMMANDS: ReadonlyArray<SlashCommand> = [
|
||||
{ name: "/help", summary: "list available commands" },
|
||||
{ name: "/clear", summary: "clear the history" },
|
||||
{ name: "/exit", summary: "leave the preview" },
|
||||
];
|
||||
|
||||
function Header(): React.ReactElement {
|
||||
return (
|
||||
<inkCompat.Box flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={BRAND} bold>
|
||||
◈ Reasonix
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Text color={FAINT}>preview · cell-diff renderer</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryRow({ entry }: { entry: HistoryEntry }): React.ReactElement {
|
||||
const { tone, glyph } = decorate(entry.role);
|
||||
return (
|
||||
<inkCompat.Box flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={tone}>{glyph}</inkCompat.Text>
|
||||
<inkCompat.Text color={entry.role === "info" ? META : undefined}>{entry.text}</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
function decorate(role: HistoryEntry["role"]): { tone: string; glyph: string } {
|
||||
switch (role) {
|
||||
case "user":
|
||||
return { tone: ACCENT, glyph: "›" };
|
||||
case "echo":
|
||||
return { tone: OK, glyph: "‹" };
|
||||
case "info":
|
||||
return { tone: META, glyph: "·" };
|
||||
case "error":
|
||||
return { tone: ERR, glyph: "✖" };
|
||||
}
|
||||
}
|
||||
|
||||
interface PromptLineProps {
|
||||
readonly value: string;
|
||||
readonly placeholder: string;
|
||||
}
|
||||
|
||||
function PromptLine({ value, placeholder }: PromptLineProps): React.ReactElement {
|
||||
const showPlaceholder = value.length === 0;
|
||||
return (
|
||||
<inkCompat.Box flexDirection="row" gap={1} marginTop={1}>
|
||||
<inkCompat.Text color={BRAND} bold>
|
||||
›
|
||||
</inkCompat.Text>
|
||||
{showPlaceholder ? (
|
||||
<inkCompat.Text dimColor>{placeholder}</inkCompat.Text>
|
||||
) : (
|
||||
<inkCompat.Text>{value}</inkCompat.Text>
|
||||
)}
|
||||
<inkCompat.Text color={FAINT}>▏</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HintBar({ streaming }: { streaming: boolean }): React.ReactElement {
|
||||
return (
|
||||
<inkCompat.Box marginTop={1}>
|
||||
<inkCompat.Text dimColor>
|
||||
{streaming ? "thinking — input paused" : "Enter submit · / for commands · Esc exit"}
|
||||
</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamingProps {
|
||||
readonly userText: string;
|
||||
readonly response: string;
|
||||
readonly frame: number;
|
||||
readonly done: boolean;
|
||||
}
|
||||
|
||||
function StreamingRow({ userText, response, frame, done }: StreamingProps): React.ReactElement {
|
||||
const glyph = done ? "‹" : (SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? "·");
|
||||
return (
|
||||
<inkCompat.Box flexDirection="column">
|
||||
<inkCompat.Box flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={ACCENT}>›</inkCompat.Text>
|
||||
<inkCompat.Text>{userText}</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
<inkCompat.Box flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={done ? OK : BRAND}>{glyph}</inkCompat.Text>
|
||||
<inkCompat.Text>{response.length > 0 ? response : "thinking…"}</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShellProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
interface Reply {
|
||||
readonly role: "echo" | "info" | "error";
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
function trySlash(
|
||||
text: string,
|
||||
onExit: () => void,
|
||||
onClear: () => void,
|
||||
): ReadonlyArray<Reply> | "cleared" | null {
|
||||
if (!text.startsWith("/")) return null;
|
||||
const [name] = text.split(/\s+/, 1);
|
||||
const cmd = name ?? text;
|
||||
if (cmd === "/exit") {
|
||||
onExit();
|
||||
return [{ role: "info", text: "exiting…" }];
|
||||
}
|
||||
if (cmd === "/clear") {
|
||||
onClear();
|
||||
return "cleared";
|
||||
}
|
||||
if (cmd === "/help") {
|
||||
return [
|
||||
{ role: "info", text: "available commands:" },
|
||||
...SLASH_COMMANDS.map((c) => ({ role: "info" as const, text: ` ${c.name} — ${c.summary}` })),
|
||||
];
|
||||
}
|
||||
return [{ role: "error", text: `unknown command: ${cmd}` }];
|
||||
}
|
||||
|
||||
interface InflightState {
|
||||
readonly userText: string;
|
||||
readonly fullResponse: string;
|
||||
readonly revealed: number;
|
||||
readonly frame: number;
|
||||
}
|
||||
|
||||
export function PreviewShell({ onExit }: ShellProps): React.ReactElement {
|
||||
const [history, setHistory] = useState<ReadonlyArray<HistoryEntry>>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [inflight, setInflight] = useState<InflightState | null>(null);
|
||||
const draftRef = useRef("");
|
||||
const nextIdRef = useRef(0);
|
||||
const inflightRef = useRef<InflightState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inflightRef.current = inflight;
|
||||
}, [inflight]);
|
||||
|
||||
const streaming = inflight !== null;
|
||||
useEffect(() => {
|
||||
if (!streaming) return;
|
||||
const spin = setInterval(() => {
|
||||
const cur = inflightRef.current;
|
||||
if (!cur) return;
|
||||
setInflight({ ...cur, frame: cur.frame + 1 });
|
||||
}, SPINNER_TICK_MS);
|
||||
const reveal = setInterval(() => {
|
||||
const cur = inflightRef.current;
|
||||
if (!cur) return;
|
||||
if (cur.revealed >= cur.fullResponse.length) {
|
||||
clearInterval(reveal);
|
||||
setTimeout(() => {
|
||||
const fin = inflightRef.current;
|
||||
if (!fin) return;
|
||||
const id = nextIdRef.current;
|
||||
nextIdRef.current = id + 2;
|
||||
setHistory((prev) => [
|
||||
...prev,
|
||||
{ id, role: "user", text: fin.userText },
|
||||
{ id: id + 1, role: "echo", text: fin.fullResponse },
|
||||
]);
|
||||
setInflight(null);
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
const next = Math.min(cur.fullResponse.length, cur.revealed + CHARS_PER_TICK);
|
||||
setInflight({ ...cur, revealed: next });
|
||||
}, TOKEN_TICK_MS);
|
||||
return () => {
|
||||
clearInterval(spin);
|
||||
clearInterval(reveal);
|
||||
};
|
||||
}, [streaming]);
|
||||
|
||||
useKeystroke((k) => {
|
||||
if (k.escape) {
|
||||
onExit();
|
||||
return;
|
||||
}
|
||||
if (inflightRef.current) return;
|
||||
if (k.return) {
|
||||
const text = draftRef.current.trim();
|
||||
if (text.length === 0) return;
|
||||
const slashResult = trySlash(text, onExit, () => {
|
||||
nextIdRef.current = 0;
|
||||
setHistory([]);
|
||||
});
|
||||
if (slashResult === "cleared") {
|
||||
draftRef.current = "";
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
if (slashResult) {
|
||||
const id = nextIdRef.current;
|
||||
nextIdRef.current = id + 1 + slashResult.length;
|
||||
setHistory((prev) => [
|
||||
...prev,
|
||||
{ id, role: "user", text },
|
||||
...slashResult.map((r, i) => ({ id: id + 1 + i, role: r.role, text: r.text })),
|
||||
]);
|
||||
draftRef.current = "";
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
setInflight({ userText: text, fullResponse: buildResponse(text), revealed: 0, frame: 0 });
|
||||
draftRef.current = "";
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
if (k.backspace) {
|
||||
const next = draftRef.current.slice(0, -1);
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
return;
|
||||
}
|
||||
if (k.ctrl || k.meta) return;
|
||||
if (k.input && k.input.length > 0) {
|
||||
const next = draftRef.current + k.input;
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<inkCompat.Box flexDirection="column">
|
||||
<Header />
|
||||
<inkCompat.Box flexDirection="column" marginTop={1}>
|
||||
<inkCompat.Static items={history}>
|
||||
{(entry) => <HistoryRow key={entry.id} entry={entry} />}
|
||||
</inkCompat.Static>
|
||||
</inkCompat.Box>
|
||||
{inflight ? (
|
||||
<inkCompat.Box marginTop={1}>
|
||||
<StreamingRow
|
||||
userText={inflight.userText}
|
||||
response={inflight.fullResponse.slice(0, inflight.revealed)}
|
||||
frame={inflight.frame}
|
||||
done={inflight.revealed >= inflight.fullResponse.length}
|
||||
/>
|
||||
</inkCompat.Box>
|
||||
) : (
|
||||
<PromptLine value={draft} placeholder="say something…" />
|
||||
)}
|
||||
<HintBar streaming={inflight !== null} />
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PreviewOptions {
|
||||
readonly stdout?: NodeJS.WriteStream;
|
||||
readonly stdin?: NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
export async function runPreview(opts: PreviewOptions = {}): Promise<void> {
|
||||
const stdout = opts.stdout ?? process.stdout;
|
||||
const stdin = opts.stdin ?? process.stdin;
|
||||
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
console.error("preview requires an interactive TTY.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pools = {
|
||||
char: new CharPool(),
|
||||
style: new StylePool(),
|
||||
hyperlink: new HyperlinkPool(),
|
||||
};
|
||||
|
||||
let resolveExit: () => void = () => {};
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const handle: Handle = mount(<PreviewShell onExit={() => resolveExit()} />, {
|
||||
viewportWidth: stdout.columns ?? 80,
|
||||
viewportHeight: stdout.rows ?? 24,
|
||||
pools,
|
||||
write: (bytes) => stdout.write(bytes),
|
||||
stdin,
|
||||
onExit: () => resolveExit(),
|
||||
});
|
||||
|
||||
const onResize = () => handle.resize(stdout.columns ?? 80, stdout.rows ?? 24);
|
||||
stdout.on("resize", onResize);
|
||||
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
stdout.off("resize", onResize);
|
||||
handle.destroy();
|
||||
stdin.pause();
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
CharPool,
|
||||
HyperlinkPool,
|
||||
type Keystroke,
|
||||
StylePool,
|
||||
Text,
|
||||
mount,
|
||||
useKeystroke,
|
||||
} from "../../renderer/index.js";
|
||||
|
||||
const ACCENT = { apply: "\x1b[36m", revert: "\x1b[39m" };
|
||||
const DIM = { apply: "\x1b[2m", revert: "\x1b[22m" };
|
||||
const FAINT_BORDER = [{ apply: "\x1b[90m", revert: "\x1b[39m" }];
|
||||
|
||||
function describeKey(k: Keystroke): string {
|
||||
if (k.escape) return "ESC";
|
||||
if (k.return) return "Return";
|
||||
if (k.tab) return "Tab";
|
||||
if (k.backspace) return "Backspace";
|
||||
if (k.delete) return "Delete";
|
||||
if (k.upArrow) return `${modPrefix(k)}↑`;
|
||||
if (k.downArrow) return `${modPrefix(k)}↓`;
|
||||
if (k.leftArrow) return `${modPrefix(k)}←`;
|
||||
if (k.rightArrow) return `${modPrefix(k)}→`;
|
||||
if (k.home) return "Home";
|
||||
if (k.end) return "End";
|
||||
if (k.pageUp) return "PageUp";
|
||||
if (k.pageDown) return "PageDown";
|
||||
if (k.ctrl && k.input) return `Ctrl+${k.input.toUpperCase()}`;
|
||||
if (k.meta && k.input) return `Alt+${k.input}`;
|
||||
return k.input || "?";
|
||||
}
|
||||
|
||||
function modPrefix(k: Keystroke): string {
|
||||
let p = "";
|
||||
if (k.ctrl) p += "Ctrl+";
|
||||
if (k.meta) p += "Alt+";
|
||||
if (k.shift) p += "Shift+";
|
||||
return p;
|
||||
}
|
||||
|
||||
function Demo({ onExit }: { onExit: () => void }) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [last, setLast] = useState<string>("(none yet)");
|
||||
|
||||
useKeystroke((k) => {
|
||||
if (k.escape) {
|
||||
onExit();
|
||||
return;
|
||||
}
|
||||
setCount((n) => n + 1);
|
||||
setLast(describeKey(k));
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={FAINT_BORDER} padding={1}>
|
||||
<Text style={[ACCENT]}>Reasonix cell-diff renderer · demo</Text>
|
||||
<Text style={[DIM]}>Press any key. ESC to exit.</Text>
|
||||
<Box paddingTop={1} flexDirection="row">
|
||||
<Text>{"Count : "}</Text>
|
||||
<Text>{String(count)}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text>{"Last : "}</Text>
|
||||
<Text>{last}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RendererDemoOptions {
|
||||
/** Optional override for stdout (used by tests). */
|
||||
stdout?: NodeJS.WriteStream;
|
||||
/** Optional override for stdin (used by tests). */
|
||||
stdin?: NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
export async function runRendererDemo(opts: RendererDemoOptions = {}): Promise<void> {
|
||||
const stdout = opts.stdout ?? process.stdout;
|
||||
const stdin = opts.stdin ?? process.stdin;
|
||||
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
console.error("renderer-demo requires an interactive TTY.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pools = {
|
||||
char: new CharPool(),
|
||||
style: new StylePool(),
|
||||
hyperlink: new HyperlinkPool(),
|
||||
};
|
||||
|
||||
let resolveExit: () => void = () => {};
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const handle = mount(<Demo onExit={() => resolveExit()} />, {
|
||||
viewportWidth: stdout.columns ?? 80,
|
||||
viewportHeight: stdout.rows ?? 24,
|
||||
pools,
|
||||
write: (bytes) => stdout.write(bytes),
|
||||
stdin,
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
handle.resize(stdout.columns ?? 80, stdout.rows ?? 24);
|
||||
};
|
||||
stdout.on("resize", onResize);
|
||||
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
stdout.off("resize", onResize);
|
||||
handle.destroy();
|
||||
stdin.pause();
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
CharPool,
|
||||
type Handle,
|
||||
HyperlinkPool,
|
||||
StylePool,
|
||||
inkCompat,
|
||||
mount,
|
||||
useKeystroke,
|
||||
} from "../../renderer/index.js";
|
||||
|
||||
interface Item {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly hint?: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
const ITEMS: ReadonlyArray<Item> = [
|
||||
{ value: "deepseek-v3", label: "DeepSeek V3", hint: "default · cheap · fast" },
|
||||
{ value: "deepseek-r1", label: "DeepSeek R1", hint: "reasoning model" },
|
||||
{ value: "deepseek-coder", label: "DeepSeek Coder", hint: "code-focused", disabled: true },
|
||||
{ value: "kimi-k2", label: "Kimi K2", hint: "alternate provider" },
|
||||
];
|
||||
|
||||
function findEnabled(items: ReadonlyArray<Item>, from: number, step: -1 | 1): number {
|
||||
if (items.length === 0) return 0;
|
||||
let i = from;
|
||||
for (let tries = 0; tries < items.length; tries++) {
|
||||
i = (i + step + items.length) % items.length;
|
||||
if (!items[i]?.disabled) return i;
|
||||
}
|
||||
return from;
|
||||
}
|
||||
|
||||
function SelectRow({ item, active, marker }: { item: Item; active: boolean; marker: string }) {
|
||||
const color = item.disabled ? "gray" : active ? "cyan" : undefined;
|
||||
return (
|
||||
<inkCompat.Box flexDirection="column">
|
||||
<inkCompat.Text color={color} bold={active} dimColor={item.disabled}>
|
||||
{`${marker} ${item.label}`}
|
||||
</inkCompat.Text>
|
||||
{item.hint ? (
|
||||
<inkCompat.Box paddingLeft={marker.length + 1}>
|
||||
<inkCompat.Text dimColor>{item.hint}</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
) : null}
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function SingleSelect({ onSubmit, onCancel }: SelectProps): React.ReactElement {
|
||||
const [index, setIndex] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
ITEMS.findIndex((i) => !i.disabled),
|
||||
),
|
||||
);
|
||||
|
||||
useKeystroke((k) => {
|
||||
if (k.upArrow) setIndex((i) => findEnabled(ITEMS, i, -1));
|
||||
else if (k.downArrow) setIndex((i) => findEnabled(ITEMS, i, +1));
|
||||
else if (k.return) {
|
||||
const item = ITEMS[index];
|
||||
if (item && !item.disabled) onSubmit(item.value);
|
||||
} else if (k.escape) onCancel();
|
||||
});
|
||||
|
||||
return (
|
||||
<inkCompat.Box flexDirection="column" marginY={1}>
|
||||
<inkCompat.Text color="cyan" bold>
|
||||
Pick a model
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Box flexDirection="column" marginTop={1}>
|
||||
{ITEMS.map((item, i) => (
|
||||
<SelectRow
|
||||
key={item.value}
|
||||
item={item}
|
||||
active={i === index}
|
||||
marker={i === index ? "▸" : " "}
|
||||
/>
|
||||
))}
|
||||
</inkCompat.Box>
|
||||
<inkCompat.Box marginTop={1}>
|
||||
<inkCompat.Text dimColor>↑/↓ navigate · Enter confirm · Esc cancel</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectDemoOptions {
|
||||
readonly stdout?: NodeJS.WriteStream;
|
||||
readonly stdin?: NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
export async function runSelectDemo(opts: SelectDemoOptions = {}): Promise<void> {
|
||||
const stdout = opts.stdout ?? process.stdout;
|
||||
const stdin = opts.stdin ?? process.stdin;
|
||||
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
console.error("select-demo requires an interactive TTY.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pools = {
|
||||
char: new CharPool(),
|
||||
style: new StylePool(),
|
||||
hyperlink: new HyperlinkPool(),
|
||||
};
|
||||
|
||||
let resolveExit: (msg: string) => void = () => {};
|
||||
const exited = new Promise<string>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const handle: Handle = mount(
|
||||
<SingleSelect
|
||||
onSubmit={(v) => resolveExit(`picked: ${v}`)}
|
||||
onCancel={() => resolveExit("(cancelled)")}
|
||||
/>,
|
||||
{
|
||||
viewportWidth: stdout.columns ?? 80,
|
||||
viewportHeight: stdout.rows ?? 24,
|
||||
pools,
|
||||
write: (bytes) => stdout.write(bytes),
|
||||
stdin,
|
||||
},
|
||||
);
|
||||
|
||||
const onResize = () => handle.resize(stdout.columns ?? 80, stdout.rows ?? 24);
|
||||
stdout.on("resize", onResize);
|
||||
|
||||
let result = "";
|
||||
try {
|
||||
result = await exited;
|
||||
} finally {
|
||||
stdout.off("resize", onResize);
|
||||
handle.destroy();
|
||||
stdin.pause();
|
||||
}
|
||||
stdout.write(`${result}\n`);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CharPool,
|
||||
type Handle,
|
||||
HyperlinkPool,
|
||||
StylePool,
|
||||
inkCompat,
|
||||
mount,
|
||||
useKeystroke,
|
||||
} from "../../renderer/index.js";
|
||||
|
||||
const BRAND = "#79c0ff";
|
||||
const FAINT = "#6e7681";
|
||||
const META = "#8b949e";
|
||||
const ACCENT = "#d2a8ff";
|
||||
const OK = "#7ee787";
|
||||
const PEND = "#484f58";
|
||||
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
||||
const SHELL_LINES = [
|
||||
" PASS src/loop.test.ts",
|
||||
" PASS src/parser.test.ts",
|
||||
" PASS src/cli/index.test.ts",
|
||||
" PASS src/cli/commands/chat.test.ts",
|
||||
" PASS src/diff/cell.test.ts",
|
||||
" PASS src/diff/screen.test.ts",
|
||||
" PASS src/renderer/layout.test.ts",
|
||||
" PASS src/renderer/diff.test.ts",
|
||||
" PASS src/renderer/serialize.test.ts",
|
||||
"",
|
||||
"Test Suites: 9 passed, 9 total",
|
||||
"Tests: 142 passed, 142 total",
|
||||
] as const;
|
||||
const RESPONSE = [
|
||||
"Working through the failing test on src/loop.test.ts.",
|
||||
"The assertion on line 42 expects the parser to drop the trailing tool-call",
|
||||
"marker, but the new tokenizer keeps it. Two paths forward — patch the",
|
||||
"tokenizer's strip step, or update the expectation.",
|
||||
].join(" ");
|
||||
|
||||
interface PlanStep {
|
||||
readonly label: string;
|
||||
readonly status: "done" | "running" | "pending";
|
||||
}
|
||||
|
||||
const PLAN_STEPS: ReadonlyArray<PlanStep> = [
|
||||
{ label: "identify the failing test", status: "done" },
|
||||
{ label: "wire the regression check", status: "running" },
|
||||
{ label: "rebuild dist", status: "pending" },
|
||||
{ label: "publish patch", status: "pending" },
|
||||
];
|
||||
|
||||
function StatusRow({ elapsedMs }: { elapsedMs: number }): React.ReactElement {
|
||||
const seconds = (elapsedMs / 1000).toFixed(1);
|
||||
const cost = (elapsedMs / 1000) * 0.0008;
|
||||
return (
|
||||
<inkCompat.Box flexDirection="row" gap={2}>
|
||||
<inkCompat.Text color={BRAND} bold>
|
||||
◈ Reasonix
|
||||
</inkCompat.Text>
|
||||
<inkCompat.Text color={META}>working</inkCompat.Text>
|
||||
<inkCompat.Text color={FAINT}>{`${seconds}s elapsed`}</inkCompat.Text>
|
||||
<inkCompat.Text color={FAINT}>{`$${cost.toFixed(4)}`}</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({ frame }: { frame: number }): React.ReactElement {
|
||||
const spin = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? "·";
|
||||
return (
|
||||
<inkCompat.Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={ACCENT}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<inkCompat.Text color={ACCENT} bold>
|
||||
⊞ Plan
|
||||
</inkCompat.Text>
|
||||
{PLAN_STEPS.map((step) => {
|
||||
const { glyph, color } = decoratePlan(step.status, spin);
|
||||
return (
|
||||
<inkCompat.Box key={step.label} flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={color}>{glyph}</inkCompat.Text>
|
||||
<inkCompat.Text dimColor={step.status === "pending"}>{step.label}</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
})}
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
function decoratePlan(status: PlanStep["status"], spin: string): { glyph: string; color: string } {
|
||||
switch (status) {
|
||||
case "done":
|
||||
return { glyph: "✓", color: OK };
|
||||
case "running":
|
||||
return { glyph: spin, color: BRAND };
|
||||
case "pending":
|
||||
return { glyph: "○", color: PEND };
|
||||
}
|
||||
}
|
||||
|
||||
const SHELL_WINDOW = 5;
|
||||
|
||||
function ShellCard({ lines, frame }: { lines: number; frame: number }): React.ReactElement {
|
||||
const total = Math.min(lines, SHELL_LINES.length);
|
||||
const startIdx = Math.max(0, total - SHELL_WINDOW);
|
||||
const visible = SHELL_LINES.slice(startIdx, total);
|
||||
const hidden = startIdx;
|
||||
const running = lines < SHELL_LINES.length;
|
||||
const spin = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? "·";
|
||||
return (
|
||||
<inkCompat.Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={BRAND}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<inkCompat.Box flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={BRAND}>{running ? spin : "✓"}</inkCompat.Text>
|
||||
<inkCompat.Text color={BRAND} bold>
|
||||
npm test
|
||||
</inkCompat.Text>
|
||||
{hidden > 0 ? (
|
||||
<inkCompat.Text color={FAINT}>{`(+${hidden} earlier)`}</inkCompat.Text>
|
||||
) : null}
|
||||
</inkCompat.Box>
|
||||
{visible.map((line, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: shell output lines are positional + append-only
|
||||
<inkCompat.Text key={`shell-${startIdx + i}`} dimColor={line.startsWith(" PASS")}>
|
||||
{line || " "}
|
||||
</inkCompat.Text>
|
||||
))}
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseCard({
|
||||
revealed,
|
||||
frame,
|
||||
}: { revealed: number; frame: number }): React.ReactElement {
|
||||
const text = RESPONSE.slice(0, revealed);
|
||||
const done = revealed >= RESPONSE.length;
|
||||
const glyph = done ? "‹" : (SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? "·");
|
||||
return (
|
||||
<inkCompat.Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={OK}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<inkCompat.Box flexDirection="row" gap={1}>
|
||||
<inkCompat.Text color={done ? OK : BRAND}>{glyph}</inkCompat.Text>
|
||||
<inkCompat.Text>{text || "thinking…"}</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShellProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function StressShell({ onExit }: ShellProps): React.ReactElement {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [planFrame, setPlanFrame] = useState(0);
|
||||
const [shellLines, setShellLines] = useState(0);
|
||||
const [shellFrame, setShellFrame] = useState(0);
|
||||
const [revealed, setRevealed] = useState(0);
|
||||
const [responseFrame, setResponseFrame] = useState(0);
|
||||
|
||||
const startedRef = useRef(Date.now());
|
||||
|
||||
useKeystroke((k) => {
|
||||
if (k.escape) onExit();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setElapsed(Date.now() - startedRef.current);
|
||||
}, 100);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setPlanFrame((f) => f + 1);
|
||||
}, 80);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setShellFrame((f) => f + 1);
|
||||
}, 80);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setShellLines((n) => Math.min(SHELL_LINES.length, n + 1));
|
||||
}, 250);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setResponseFrame((f) => f + 1);
|
||||
}, 80);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setRevealed((r) => Math.min(RESPONSE.length, r + 4));
|
||||
}, 33);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<inkCompat.Box flexDirection="column">
|
||||
<StatusRow elapsedMs={elapsed} />
|
||||
<PlanCard frame={planFrame} />
|
||||
<ShellCard lines={shellLines} frame={shellFrame} />
|
||||
<ResponseCard revealed={revealed} frame={responseFrame} />
|
||||
<inkCompat.Box marginTop={1}>
|
||||
<inkCompat.Text dimColor>stress mode · 4 concurrent live regions · Esc exit</inkCompat.Text>
|
||||
</inkCompat.Box>
|
||||
</inkCompat.Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StressDemoOptions {
|
||||
readonly stdout?: NodeJS.WriteStream;
|
||||
readonly stdin?: NodeJS.ReadStream;
|
||||
}
|
||||
|
||||
export async function runStressDemo(opts: StressDemoOptions = {}): Promise<void> {
|
||||
const stdout = opts.stdout ?? process.stdout;
|
||||
const stdin = opts.stdin ?? process.stdin;
|
||||
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
console.error("stress-demo requires an interactive TTY.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pools = {
|
||||
char: new CharPool(),
|
||||
style: new StylePool(),
|
||||
hyperlink: new HyperlinkPool(),
|
||||
};
|
||||
|
||||
let resolveExit: () => void = () => {};
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const handle: Handle = mount(<StressShell onExit={() => resolveExit()} />, {
|
||||
viewportWidth: stdout.columns ?? 80,
|
||||
viewportHeight: stdout.rows ?? 30,
|
||||
pools,
|
||||
write: (bytes) => stdout.write(bytes),
|
||||
stdin,
|
||||
onExit: () => resolveExit(),
|
||||
});
|
||||
|
||||
const onResize = () => handle.resize(stdout.columns ?? 80, stdout.rows ?? 30);
|
||||
stdout.on("resize", onResize);
|
||||
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
stdout.off("resize", onResize);
|
||||
handle.destroy();
|
||||
stdin.pause();
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,10 @@ import { diffCommand } from "./commands/diff.js";
|
||||
import { doctorCommand } from "./commands/doctor.js";
|
||||
import { eventsCommand } from "./commands/events.js";
|
||||
import { indexCommand } from "./commands/index.js";
|
||||
import { mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { mcpListCommand } from "./commands/mcp.js";
|
||||
import { mcpBrowseCommand } from "./commands/mcp-browse.js";
|
||||
import { formatMcpInspectFailure, mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { mcpInstallCommand, mcpListCommand, mcpSearchCommand } from "./commands/mcp.js";
|
||||
import { pruneSessionsCommand } from "./commands/prune-sessions.js";
|
||||
import { replayCommand } from "./commands/replay.js";
|
||||
import { runCommand } from "./commands/run.js";
|
||||
import { sessionsCommand } from "./commands/sessions.js";
|
||||
@@ -262,6 +264,15 @@ program
|
||||
sessionsCommand({ name, verbose: !!opts.verbose });
|
||||
});
|
||||
|
||||
program
|
||||
.command("prune-sessions")
|
||||
.description(t("cli.pruneSessions"))
|
||||
.option("--days <n>", t("ui.pruneDaysHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--dry-run", t("ui.pruneDryRunHint"))
|
||||
.action((opts) => {
|
||||
pruneSessionsCommand({ days: opts.days, dryRun: !!opts.dryRun });
|
||||
});
|
||||
|
||||
program
|
||||
.command("events <name>")
|
||||
.description(t("cli.events"))
|
||||
@@ -322,8 +333,77 @@ mcp
|
||||
.command("list")
|
||||
.description(t("ui.mcpListDescription"))
|
||||
.option("--json", t("ui.jsonHintCatalog"))
|
||||
.action((opts) => {
|
||||
mcpListCommand({ json: !!opts.json });
|
||||
.option("--local", t("ui.mcpLocalHint"))
|
||||
.option("--refresh", t("ui.mcpRefreshHint"))
|
||||
.option("--limit <n>", t("ui.mcpLimitHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--pages <n>", t("ui.mcpPagesHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--all", t("ui.mcpAllHint"))
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await mcpListCommand({
|
||||
json: !!opts.json,
|
||||
local: !!opts.local,
|
||||
refresh: !!opts.refresh,
|
||||
limit: typeof opts.limit === "number" && opts.limit > 0 ? opts.limit : undefined,
|
||||
pages: typeof opts.pages === "number" && opts.pages > 0 ? opts.pages : undefined,
|
||||
all: !!opts.all,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp list failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("search <query>")
|
||||
.description(t("ui.mcpSearchDescription"))
|
||||
.option("--json", t("ui.jsonHintCatalog"))
|
||||
.option("--refresh", t("ui.mcpRefreshHint"))
|
||||
.option("--limit <n>", t("ui.mcpLimitHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--max-pages <n>", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10))
|
||||
.action(async (query: string, opts) => {
|
||||
try {
|
||||
await mcpSearchCommand(query, {
|
||||
json: !!opts.json,
|
||||
refresh: !!opts.refresh,
|
||||
limit: typeof opts.limit === "number" && opts.limit > 0 ? opts.limit : undefined,
|
||||
maxPages:
|
||||
typeof opts.maxPages === "number" && opts.maxPages > 0 ? opts.maxPages : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp search failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("install <name>")
|
||||
.description(t("ui.mcpInstallDescription"))
|
||||
.option("--refresh", t("ui.mcpRefreshHint"))
|
||||
.option("--max-pages <n>", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10))
|
||||
.action(async (name: string, opts) => {
|
||||
try {
|
||||
await mcpInstallCommand(name, {
|
||||
refresh: !!opts.refresh,
|
||||
maxPages:
|
||||
typeof opts.maxPages === "number" && opts.maxPages > 0 ? opts.maxPages : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp install failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("browse")
|
||||
.description(t("ui.mcpBrowseDescription"))
|
||||
.action(async () => {
|
||||
try {
|
||||
await mcpBrowseCommand();
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp browse failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
mcp
|
||||
@@ -334,70 +414,13 @@ mcp
|
||||
try {
|
||||
await mcpInspectCommand({ spec, json: !!opts.json });
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp inspect failed: ${(err as Error).message}\n`);
|
||||
process.stderr.write(`mcp inspect failed: ${formatMcpInspectFailure(err)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.command("version").description(t("cli.version")).action(versionCommand);
|
||||
|
||||
program
|
||||
.command("renderer-demo")
|
||||
.description("experimental — interactive cell-diff renderer demo (separate from the main TUI)")
|
||||
.action(async () => {
|
||||
const { runRendererDemo } = await import("./commands/renderer-demo.js");
|
||||
await runRendererDemo();
|
||||
});
|
||||
|
||||
program
|
||||
.command("banner-demo")
|
||||
.description("experimental — welcome banner rendered through the ink-compat shim")
|
||||
.action(async () => {
|
||||
const { runBannerDemo } = await import("./commands/banner-demo.js");
|
||||
await runBannerDemo();
|
||||
});
|
||||
|
||||
program
|
||||
.command("select-demo")
|
||||
.description("experimental — single-select list rendered through the ink-compat shim")
|
||||
.action(async () => {
|
||||
const { runSelectDemo } = await import("./commands/select-demo.js");
|
||||
await runSelectDemo();
|
||||
});
|
||||
|
||||
program
|
||||
.command("preview")
|
||||
.description("experimental — minimal chat shell on the cell-diff renderer (echo mode)")
|
||||
.action(async () => {
|
||||
const { runPreview } = await import("./commands/preview.js");
|
||||
await runPreview();
|
||||
});
|
||||
|
||||
program
|
||||
.command("stress-demo")
|
||||
.description("experimental — 4 concurrent live regions exercising the cell-diff renderer")
|
||||
.action(async () => {
|
||||
const { runStressDemo } = await import("./commands/stress-demo.js");
|
||||
await runStressDemo();
|
||||
});
|
||||
|
||||
program
|
||||
.command("card-demo")
|
||||
.description("experimental — full chat-card lifecycle demo (reasoning / tool / plan / response)")
|
||||
.action(async () => {
|
||||
const { runCardDemo } = await import("./commands/card-demo.js");
|
||||
await runCardDemo();
|
||||
});
|
||||
|
||||
program
|
||||
.command("flicker-demo")
|
||||
.description("experimental — reproduces the streaming + modal flicker scenario for testing")
|
||||
.option("--real-modal", "use real ApprovalCard + useReserveRows (mirror live chat path)")
|
||||
.action(async (cliOpts: { realModal?: boolean }) => {
|
||||
const { runFlickerDemo } = await import("./commands/flicker-demo.js");
|
||||
await runFlickerDemo({ realModal: cliOpts.realModal });
|
||||
});
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.description(t("cli.update"))
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}` : "";
|
||||
|
||||
|
||||
@@ -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,6 +1,8 @@
|
||||
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 } 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";
|
||||
|
||||
@@ -9,28 +11,25 @@ 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" marginTop={1}>
|
||||
<Card tone={tone}>
|
||||
<CardHeader
|
||||
glyph="⎇"
|
||||
tone={tone}
|
||||
title={card.done ? "branching done" : "branching"}
|
||||
meta={[`${card.completed} of ${card.total} samples`]}
|
||||
/>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={tone}>⎇</Text>
|
||||
<Text color={tone} bold>
|
||||
{card.done ? "branching done" : "branching"}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{`· ${card.completed} of ${card.total} samples`}</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2} 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>
|
||||
</Box>
|
||||
{!card.done && card.completed > 0 ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={FG.faint}>
|
||||
{`latest · #${card.latestIndex} · T=${card.latestTemperature.toFixed(2)} · ${card.latestUncertainties} unc`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={FG.faint}>
|
||||
{`latest · #${card.latestIndex} · T=${card.latestTemperature.toFixed(2)} · ${card.latestUncertainties} unc`}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { CtxCard as CtxCardData } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
@@ -9,7 +11,7 @@ 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 (
|
||||
<Box paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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>
|
||||
@@ -27,35 +29,30 @@ export function CtxCard({ card }: { card: CtxCardData }): React.ReactElement {
|
||||
const usedPct = (used / cap) * 100;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.brand}>⌘</Text>
|
||||
<Text color={TONE.brand} bold>
|
||||
context
|
||||
</Text>
|
||||
<Text color={FG.faint}>
|
||||
{`· ${used.toLocaleString()} / ${cap.toLocaleString()} (${usedPct.toFixed(1)}%)`}
|
||||
</Text>
|
||||
</Box>
|
||||
<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 ? (
|
||||
<>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={FG.faint}>
|
||||
{`top tools · ${card.toolsCount} total · ${card.logMessages} log msgs`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={FG.faint}>
|
||||
{`top tools · ${card.toolsCount} total · ${card.logMessages} log msgs`}
|
||||
</Text>
|
||||
{card.topTools.slice(0, 5).map((t) => (
|
||||
<Box key={`${t.turn}-${t.name}`} paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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 } 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";
|
||||
|
||||
@@ -21,27 +23,24 @@ const LINE_GLYPH = {
|
||||
export function DiffCard({ card }: { card: DiffCardData }): React.ReactElement {
|
||||
const showFooter = card.hunks.length > 0;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.warn}>±</Text>
|
||||
<Text bold>{card.file}</Text>
|
||||
<Text color={TONE.ok}>{`+${card.stats.add}`}</Text>
|
||||
<Text color={TONE.err}>{`-${card.stats.del}`}</Text>
|
||||
</Box>
|
||||
<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">
|
||||
<Box paddingLeft={2}>
|
||||
<Text italic color={FG.faint}>
|
||||
{hunk.header}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text italic color={FG.faint}>
|
||||
{hunk.header}
|
||||
</Text>
|
||||
{hunk.lines.map((line, li) => (
|
||||
<Box
|
||||
key={`${card.id}:${hunk.header}:${li}`}
|
||||
paddingLeft={2}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
>
|
||||
<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}
|
||||
@@ -51,7 +50,7 @@ export function DiffCard({ card }: { card: DiffCardData }): React.ReactElement {
|
||||
</Box>
|
||||
))}
|
||||
{showFooter && (
|
||||
<Box paddingLeft={2} flexDirection="row" gap={2}>
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<Text bold color={TONE.ok}>
|
||||
[a] apply
|
||||
</Text>
|
||||
@@ -61,6 +60,6 @@ export function DiffCard({ card }: { card: DiffCardData }): React.ReactElement {
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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 } 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";
|
||||
|
||||
@@ -13,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> = {
|
||||
@@ -30,16 +32,10 @@ export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactEleme
|
||||
const summary = `${card.checks.length} checks · ${ok} passed${warn > 0 ? ` · ${warn} warn` : ""}${fail > 0 ? ` · ${fail} fail` : ""}`;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={CARD.tool.color}>⚕</Text>
|
||||
<Text color={CARD.tool.color} bold>
|
||||
doctor
|
||||
</Text>
|
||||
<Text color={FG.faint}>{`· ${summary}`}</Text>
|
||||
</Box>
|
||||
<Card tone={CARD.tool.color}>
|
||||
<CardHeader glyph="⚕" tone={CARD.tool.color} title="doctor" meta={[summary]} />
|
||||
{card.checks.map((c) => (
|
||||
<Box key={c.label} paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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)}
|
||||
@@ -48,6 +44,6 @@ export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactEleme
|
||||
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_TAG[c.level]}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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 } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { ErrorCard as ErrorCardData } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
@@ -9,7 +11,7 @@ const STACK_TAIL = 5;
|
||||
export function ErrorCard({ card }: { card: ErrorCardData }): React.ReactElement {
|
||||
const retryNote =
|
||||
card.retries !== undefined && card.retries > 0
|
||||
? `· ${card.retries} retr${card.retries === 1 ? "y" : "ies"}`
|
||||
? `${card.retries} retr${card.retries === 1 ? "y" : "ies"}`
|
||||
: null;
|
||||
const stackLines = card.stack ? card.stack.split("\n") : [];
|
||||
const stackTrunc = stackLines.length > STACK_TAIL;
|
||||
@@ -19,38 +21,33 @@ export function ErrorCard({ card }: { card: ErrorCardData }): React.ReactElement
|
||||
const messageLines = card.message.split("\n");
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.err}>✖</Text>
|
||||
<Text color={TONE.err} bold>
|
||||
{card.title || "error"}
|
||||
</Text>
|
||||
{retryNote ? <Text color={FG.faint}>{retryNote}</Text> : null}
|
||||
</Box>
|
||||
<Card tone={TONE.err}>
|
||||
<CardHeader
|
||||
glyph="✖"
|
||||
tone={TONE.err}
|
||||
title={card.title || "error"}
|
||||
meta={retryNote ? [retryNote] : undefined}
|
||||
/>
|
||||
{messageLines.map((line, i) => (
|
||||
<Box key={`${card.id}:msg:${i}`} paddingLeft={2}>
|
||||
<Text color={TONE.err}>{line || " "}</Text>
|
||||
</Box>
|
||||
<Text key={`${card.id}:msg:${i}`} color={TONE.err}>
|
||||
{line || " "}
|
||||
</Text>
|
||||
))}
|
||||
{hasStack ? (
|
||||
<>
|
||||
<Box paddingLeft={2} marginTop={1}>
|
||||
<Text color={FG.meta}>stack trace</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={FG.meta}>stack trace</Text>
|
||||
{stackHidden > 0 ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={FG.faint}>
|
||||
{`⋮ ${stackHidden} earlier stack line${stackHidden === 1 ? "" : "s"} hidden`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={FG.faint}>
|
||||
{`⋮ ${stackHidden} earlier stack line${stackHidden === 1 ? "" : "s"} hidden`}
|
||||
</Text>
|
||||
) : null}
|
||||
{stackVisible.map((line, i) => (
|
||||
<Box key={`${card.id}:stk:${stackHidden + i}`} paddingLeft={2}>
|
||||
<Text color={FG.meta}>{line || " "}</Text>
|
||||
</Box>
|
||||
<Text key={`${card.id}:stk:${stackHidden + i}`} color={FG.meta}>
|
||||
{line || " "}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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 } 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";
|
||||
|
||||
@@ -40,39 +42,32 @@ 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" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.meta}>⌑</Text>
|
||||
<Text bold color={FG.sub}>
|
||||
context
|
||||
</Text>
|
||||
{summary ? <Text color={FG.faint}>{`· ${summary}`}</Text> : null}
|
||||
<Text color={FG.faint}>{`· ${tokens}`}</Text>
|
||||
</Box>
|
||||
<Card tone={FG.meta}>
|
||||
<CardHeader
|
||||
glyph="⌑"
|
||||
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);
|
||||
const shown = all.slice(0, 5);
|
||||
const remaining = all.length - shown.length;
|
||||
return (
|
||||
<Box key={category} flexDirection="column">
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={FG.faint}>{`${CATEGORY_LABEL[category]} (${counts[category]})`}</Text>
|
||||
</Box>
|
||||
<Text color={FG.faint}>{`${CATEGORY_LABEL[category]} (${counts[category]})`}</Text>
|
||||
{shown.map((entry) => (
|
||||
<Box key={`${category}:${entry.summary}`} paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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>
|
||||
</Box>
|
||||
))}
|
||||
{remaining > 0 ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={FG.faint}>{`⋮ +${remaining} more`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{remaining > 0 ? <Text color={FG.faint}>{`⋮ +${remaining} more`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +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 { 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 { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
const STATUS_GLYPH: Record<PlanStep["status"], string> = {
|
||||
queued: "○",
|
||||
@@ -15,41 +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 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 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" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.accent}>⊞</Text>
|
||||
<Text color={TONE.accent} bold>
|
||||
{card.title}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{`· ${progress}`}</Text>
|
||||
</Box>
|
||||
{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 : FG.sub;
|
||||
return (
|
||||
<Box key={step.id} paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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> : null}
|
||||
{isActive ? <Text color={TONE_ACTIVE.brand}>← in progress</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</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,16 +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 { 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 { FG, TONE } from "../theme/tokens.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
/** 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;
|
||||
/** Once settled, only the conclusion is actionable; the rest is in `/reasoning last`. */
|
||||
const SETTLED_TAIL_LINES = 2;
|
||||
const BODY_PAD = 2;
|
||||
|
||||
export function ReasoningCard({
|
||||
card,
|
||||
@@ -22,13 +24,14 @@ 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 tone = card.aborted ? TONE.err : card.streaming ? TONE_ACTIVE.accent : TONE.accent;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Card tone={tone}>
|
||||
<ReasoningHeader card={card} />
|
||||
{showBody &&
|
||||
(card.streaming ? (
|
||||
@@ -36,30 +39,38 @@ export function ReasoningCard({
|
||||
) : (
|
||||
<SettledPreview card={card} allLines={allLines} lineCells={lineCells} />
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningHeader({ card }: { card: ReasoningCardData }): React.ReactElement {
|
||||
const streamingActive = card.streaming && !card.aborted;
|
||||
const headColor = card.aborted ? TONE.err : streamingActive ? TONE.accent : TONE.accent;
|
||||
const headColor = card.aborted ? TONE.err : streamingActive ? TONE_ACTIVE.accent : TONE.accent;
|
||||
const glyph = streamingActive ? "◇" : "◆";
|
||||
const meta = headerMeta(card);
|
||||
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 flexDirection="row" gap={1}>
|
||||
<Text color={headColor}>{glyph}</Text>
|
||||
<Text color={headColor} bold>
|
||||
{streamingActive ? "reasoning…" : card.aborted ? "reasoning (aborted)" : "reasoning"}
|
||||
</Text>
|
||||
{meta ? <Text color={FG.faint}>{`· ${meta}`}</Text> : null}
|
||||
{duration ? <Text color={FG.faint}>{`· ${duration}`}</Text> : null}
|
||||
{streamingActive ? (
|
||||
<Box flexDirection="row">
|
||||
<Spinner kind="braille" color={TONE.accent} />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<CardHeader
|
||||
glyph={glyph}
|
||||
tone={headColor}
|
||||
title={title}
|
||||
titleColor={PILL_SECTION.reason.fg}
|
||||
titleBg={PILL_SECTION.reason.bg}
|
||||
meta={meta.length > 0 ? meta : undefined}
|
||||
right={
|
||||
<>
|
||||
{streamingActive ? <Spinner kind="braille" color={TONE_ACTIVE.accent} /> : null}
|
||||
{modelBadge ? (
|
||||
<Pill label={modelBadge.label} {...PILL_MODEL[modelBadge.kind]} bold={false} />
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,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>
|
||||
@@ -149,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,6 +1,8 @@
|
||||
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 } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { SearchCard as SearchCardData, SearchHit } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
@@ -14,24 +16,21 @@ export function SearchCard({ card }: { card: SearchCardData }): React.ReactEleme
|
||||
const grouped = groupByFile(card.hits.slice(0, 10));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.info}>⊙</Text>
|
||||
<Text color={TONE.info} bold>
|
||||
search
|
||||
</Text>
|
||||
<Text color={FG.body}>{`"${card.query}"`}</Text>
|
||||
<Text color={FG.faint}>{`· ${stats} · ${elapsed}`}</Text>
|
||||
</Box>
|
||||
<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">
|
||||
<Box paddingLeft={2}>
|
||||
<Text bold color={FG.strong}>
|
||||
{file}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text bold color={FG.strong}>
|
||||
{file}
|
||||
</Text>
|
||||
{hits.map((h, i) => (
|
||||
<Box key={`${file}:${h.line}:${i}`} paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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>
|
||||
@@ -39,11 +38,9 @@ export function SearchCard({ card }: { card: SearchCardData }): React.ReactEleme
|
||||
</Box>
|
||||
))}
|
||||
{card.hits.length > 10 ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={FG.faint}>{`⋮ +${card.hits.length - 10} more hits`}</Text>
|
||||
</Box>
|
||||
<Text color={FG.faint}>{`⋮ +${card.hits.length - 10} more hits`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import React from "react";
|
||||
import { clipToCells, wrapToCells } from "../../../frame/width.js";
|
||||
import { useReserveRows } from "../layout/viewport-budget.js";
|
||||
import { Markdown } from "../markdown.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 { FG, TONE } from "../theme/tokens.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
const BODY_PAD = 2;
|
||||
/** Streaming preview tail length — bounded live region so chunks don't thrash whole-card layout. */
|
||||
const STREAMING_PREVIEW_LINES = 4;
|
||||
|
||||
@@ -20,58 +22,48 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
|
||||
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 (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.ok}>‹</Text>
|
||||
<Text color={TONE.ok} bold>
|
||||
reply
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={BODY_PAD} flexDirection="column">
|
||||
<Markdown text={card.text} />
|
||||
</Box>
|
||||
</Box>
|
||||
<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.brand;
|
||||
const glyph = aborted ? "‹" : "▸";
|
||||
const headColor = aborted ? TONE.err : TONE_ACTIVE.brand;
|
||||
const glyph = aborted ? "‹" : "◈";
|
||||
const headLabel = aborted ? "aborted" : "writing…";
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={headColor}>{glyph}</Text>
|
||||
<Text color={headColor} bold>
|
||||
{headLabel}
|
||||
</Text>
|
||||
{aborted ? null : (
|
||||
<Box flexDirection="row">
|
||||
<Spinner kind="braille" color={TONE.brand} />
|
||||
</Box>
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
<Box key={`${card.id}:${allLines.length - visible.length + i}`} flexDirection="row">
|
||||
<Text color={aborted ? FG.meta : FG.body}>{clipToCells(line, lineCells)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{aborted ? (
|
||||
<Box paddingLeft={BODY_PAD}>
|
||||
<Text color={FG.faint}>[truncated by esc]</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
{aborted ? <Text color={FG.faint}>[truncated by esc]</Text> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
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 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 { 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,
|
||||
};
|
||||
@@ -13,81 +16,130 @@ const STATUS_COLOR: Record<SubAgentCardData["status"], string> = {
|
||||
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" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={headColor}>{headGlyph}</Text>
|
||||
<Text color={TONE.violet} bold>
|
||||
subagent
|
||||
</Text>
|
||||
<Text>{card.name}</Text>
|
||||
<Text color={headColor}>{`· ${card.status}`}</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={FG.sub}>{card.task}</Text>
|
||||
</Box>
|
||||
<CardWrap tone={headColor}>
|
||||
<CardHeader
|
||||
glyph={headGlyph}
|
||||
tone={headColor}
|
||||
title="subagent"
|
||||
titleColor={TONE.violet}
|
||||
subtitle={card.task}
|
||||
meta={headerMeta}
|
||||
/>
|
||||
{card.name ? <Text color={FG.faint}>{`agent · ${card.name}`}</Text> : null}
|
||||
{card.tools && card.tools.length > 0 && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={FG.faint}>{`tools · ${card.tools.join(", ")}`}</Text>
|
||||
</Box>
|
||||
<Text color={FG.faint}>{`tools · ${card.tools.join(", ")}`}</Text>
|
||||
)}
|
||||
{card.children.map((child) => (
|
||||
<Box key={child.id} paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<Text color={TONE.violet}>▎</Text>
|
||||
<ChildSummary card={child} />
|
||||
<Box key={child.id} flexDirection="row" gap={1}>
|
||||
{inLive ? null : <Text color={TONE.violet}>▎</Text>}
|
||||
<ChildRow card={child} />
|
||||
</Box>
|
||||
))}
|
||||
</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>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
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,6 +1,8 @@
|
||||
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 } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { TaskCard as TaskCardData, TaskStep } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
@@ -13,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: TONE.brand,
|
||||
running: TONE.warn,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
};
|
||||
@@ -33,17 +35,16 @@ const TASK_GLYPH: Record<TaskCardData["status"], string> = {
|
||||
export function TaskCard({ card }: { card: TaskCardData }): React.ReactElement {
|
||||
const elapsed = `${(card.elapsedMs / 1000).toFixed(1)}s`;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TASK_COLOR[card.status]}>{TASK_GLYPH[card.status]}</Text>
|
||||
<Text color={TASK_COLOR[card.status]} bold>
|
||||
{`step ${card.index}/${card.total}`}
|
||||
</Text>
|
||||
<Text color={FG.body}>{card.title}</Text>
|
||||
<Text color={FG.faint}>{`· ${elapsed} · ${card.status}`}</Text>
|
||||
</Box>
|
||||
<Card tone={TASK_COLOR[card.status]}>
|
||||
<CardHeader
|
||||
glyph={TASK_GLYPH[card.status]}
|
||||
tone={TASK_COLOR[card.status]}
|
||||
title={`step ${card.index}/${card.total}`}
|
||||
subtitle={card.title}
|
||||
meta={[elapsed, card.status]}
|
||||
/>
|
||||
{card.steps.map((step) => (
|
||||
<Box key={step.id} paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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)}
|
||||
@@ -55,6 +56,6 @@ export function TaskCard({ card }: { card: TaskCardData }): React.ReactElement {
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +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 { 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 { FG, TONE } from "../theme/tokens.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
const READ_TAIL = 2;
|
||||
const OTHER_TAIL = 5;
|
||||
const BODY_PAD = 2;
|
||||
|
||||
/** 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 {
|
||||
@@ -22,7 +23,7 @@ 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_PAD - 4);
|
||||
const lineCells = Math.max(20, cols - 4);
|
||||
const argsLabel = formatArgsSummary(card.args);
|
||||
const allLines = card.output.length > 0 ? card.output.split("\n") : [];
|
||||
const tail = tailLinesFor(card.name);
|
||||
@@ -36,48 +37,48 @@ export function ToolCard({ card }: { card: ToolCardData }): React.ReactElement {
|
||||
// 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" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={headColor}>{statusGlyph(status)}</Text>
|
||||
<Text color={headColor} bold>
|
||||
{card.name}
|
||||
</Text>
|
||||
{argsLabel ? <Text color={FG.faint}>{argsLabel}</Text> : null}
|
||||
{card.retry ? (
|
||||
<Text color={TONE.warn} bold>{`↻ ${card.retry.attempt}/${card.retry.max}`}</Text>
|
||||
) : null}
|
||||
{card.rejected ? (
|
||||
<Text color={TONE.err} bold>
|
||||
rejected
|
||||
</Text>
|
||||
) : null}
|
||||
<Text color={FG.faint}>{metaTrail(card)}</Text>
|
||||
{status === "running" ? (
|
||||
<Box flexDirection="row">
|
||||
<Spinner kind="braille" color={TONE.brand} bold />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Card tone={headColor}>
|
||||
<CardHeader
|
||||
glyph={statusGlyph(status)}
|
||||
tone={headColor}
|
||||
title={card.name}
|
||||
subtitle={argsLabel || undefined}
|
||||
meta={meta.length > 0 ? meta : undefined}
|
||||
right={
|
||||
status === "running" ? (
|
||||
<Spinner kind="braille" color={TONE_ACTIVE.brand} bold />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{showBody && (
|
||||
<>
|
||||
{hidden > 0 ? (
|
||||
<Box paddingLeft={BODY_PAD}>
|
||||
<Text color={FG.faint}>
|
||||
{`⋮ ${hidden} earlier line${hidden === 1 ? "" : "s"} (use /tool to read full)`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={FG.faint}>
|
||||
{`⋮ ${hidden} earlier line${hidden === 1 ? "" : "s"} (use /tool to read full)`}
|
||||
</Text>
|
||||
) : null}
|
||||
{visible.map((line, i) => (
|
||||
<Box key={`${card.id}:${hidden + i}`} paddingLeft={BODY_PAD}>
|
||||
<Text color={errColor} dimColor={!card.exitCode || card.exitCode === 0}>
|
||||
{clipToCells(line, lineCells) || " "}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
key={`${card.id}:${hidden + i}`}
|
||||
color={errColor}
|
||||
dimColor={!card.exitCode || card.exitCode === 0}
|
||||
>
|
||||
{clipToCells(line, lineCells) || " "}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,11 +116,11 @@ function headerColorFor(s: ToolStatus): string {
|
||||
case "aborted":
|
||||
return TONE.err;
|
||||
case "running":
|
||||
return TONE.brand;
|
||||
return TONE_ACTIVE.brand;
|
||||
}
|
||||
}
|
||||
|
||||
function metaTrail(card: ToolCardData): string {
|
||||
function metaTrail(card: ToolCardData): string[] {
|
||||
const parts: string[] = [];
|
||||
const inputBytes = largestStringInputBytes(card.args);
|
||||
if (inputBytes !== null) parts.push(`${formatBytes(inputBytes)} in`);
|
||||
@@ -133,7 +134,7 @@ function metaTrail(card: ToolCardData): string {
|
||||
) {
|
||||
parts.push(`exit ${card.exitCode}`);
|
||||
}
|
||||
return parts.length > 0 ? `· ${parts.join(" · ")}` : "";
|
||||
return parts;
|
||||
}
|
||||
|
||||
function formatArgsSummary(args: unknown): string {
|
||||
|
||||
@@ -1,8 +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 { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { UsageCard as UsageCardData } from "../state/cards.js";
|
||||
import { FG, TONE, formatCNY } from "../theme/tokens.js";
|
||||
import { FG, TONE, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const BAR_CELLS = 30;
|
||||
|
||||
@@ -29,20 +31,13 @@ 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 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" marginTop={1}>
|
||||
<Card tone={FG.meta}>
|
||||
<CardHeader glyph="Σ" tone={FG.meta} title="usage" meta={headerMeta} />
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.brand}>Σ</Text>
|
||||
<Text color={TONE.brand} bold>
|
||||
usage
|
||||
</Text>
|
||||
<Text color={FG.faint}>{`turn ${card.turn}`}</Text>
|
||||
<Text color={FG.faint}>{`· ${formatCNY(card.cost)}`}</Text>
|
||||
{elapsed ? <Text color={FG.faint}>{elapsed}</Text> : null}
|
||||
</Box>
|
||||
<Box paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<Text color={FG.sub}>prompt</Text>
|
||||
{bar(promptRatio, TONE.brand)}
|
||||
<Text bold color={FG.body}>
|
||||
@@ -50,36 +45,40 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
|
||||
</Text>
|
||||
<Text color={FG.faint}>{`/ 1M · ${(promptRatio * 100).toFixed(1)}%`}</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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>
|
||||
</Box>
|
||||
<Box paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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>
|
||||
</Box>
|
||||
<Box paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<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>
|
||||
</Box>
|
||||
<Box paddingLeft={2} flexDirection="row" gap={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.faint}>session</Text>
|
||||
<Text bold color={FG.body}>{`⛁ ${formatCNY(card.sessionCost, 3)}`}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{`⛁ ${formatCost(card.sessionCost, card.balanceCurrency, 3)}`}
|
||||
</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<>
|
||||
<Text color={FG.faint}>· balance</Text>
|
||||
<Text bold color={TONE.brand}>{`¥${card.balance.toFixed(2)}`}</Text>
|
||||
<Text bold color={TONE.brand}>
|
||||
{formatBalance(card.balance, card.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,16 +86,16 @@ function CompactUsageRow({ card }: { card: UsageCardData }): React.ReactElement
|
||||
const elapsed = card.elapsedMs !== undefined ? ` · ${(card.elapsedMs / 1000).toFixed(1)}s` : "";
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} marginTop={1}>
|
||||
<Text color={TONE.brand}>Σ</Text>
|
||||
<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>
|
||||
<Text color={FG.faint}>{`· ${formatCost(card.cost, card.balanceCurrency)}${elapsed}`}</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<Text color={TONE.brand}>{`· ¥${card.balance.toFixed(2)}`}</Text>
|
||||
<Text color={TONE.brand}>{`· ${formatBalance(card.balance, card.balanceCurrency)}`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,24 +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, TONE } from "../theme/tokens.js";
|
||||
import { formatRelativeTime } from "./time.js";
|
||||
|
||||
export function UserCard({ card }: { card: UserCardData }): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.accent}>›</Text>
|
||||
<Text bold color={FG.sub}>
|
||||
you
|
||||
</Text>
|
||||
<Text color={FG.faint}>{`· ${formatRelativeTime(card.ts)}`}</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<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,25 +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 { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { WarnCard as WarnCardData } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
export function WarnCard({ card }: { card: WarnCardData }): React.ReactElement {
|
||||
const messageLines = card.message.length > 0 ? card.message.split("\n") : [];
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.warn}>⚠</Text>
|
||||
<Text color={TONE.warn} bold>
|
||||
{card.title}
|
||||
</Text>
|
||||
{card.detail ? <Text color={FG.faint}>{`· ${card.detail}`}</Text> : null}
|
||||
</Box>
|
||||
<Card tone={TONE.warn}>
|
||||
<CardHeader
|
||||
glyph="⚠"
|
||||
tone={TONE.warn}
|
||||
title={card.title}
|
||||
meta={card.detail ? [card.detail] : undefined}
|
||||
/>
|
||||
{messageLines.map((line, i) => (
|
||||
<Box key={`${card.id}:${i}`} paddingLeft={2}>
|
||||
<Text color={FG.body}>{line || " "}</Text>
|
||||
</Box>
|
||||
<Text key={`${card.id}:${i}`} color={FG.body}>
|
||||
{line || " "}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { type MutableRefObject, useCallback } from "react";
|
||||
import type { LoopEvent } from "../../../loop.js";
|
||||
import { recordFromLoopEvent, writeRecord } from "../../../transcript/log.js";
|
||||
|
||||
/** Returns a `LoopEvent` writer that no-ops when no transcript was opened. Wraps `recordFromLoopEvent` + `writeRecord` so callers don't carry the model/prefix metadata. */
|
||||
export function useTranscriptWriter(
|
||||
transcriptRef: MutableRefObject<WriteStream | null>,
|
||||
model: string,
|
||||
prefixHash: string,
|
||||
): (ev: LoopEvent) => void {
|
||||
return useCallback(
|
||||
(ev: LoopEvent) => {
|
||||
const stream = transcriptRef.current;
|
||||
if (!stream) return;
|
||||
writeRecord(stream, recordFromLoopEvent(ev, { model, prefixHash }));
|
||||
},
|
||||
[transcriptRef, model, prefixHash],
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Static } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { CardRenderer } from "../cards/CardRenderer.js";
|
||||
import { ActiveCardContext } from "../primitives/Card.js";
|
||||
import type { Card } from "../state/cards.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
|
||||
@@ -11,12 +12,9 @@ function isSettled(card: Card): boolean {
|
||||
case "streaming":
|
||||
case "tool":
|
||||
case "branch":
|
||||
return card.done;
|
||||
return card.done || !!card.aborted;
|
||||
case "reasoning":
|
||||
return !card.streaming;
|
||||
case "plan":
|
||||
if (card.variant !== "active") return true;
|
||||
return card.steps.every((s) => s.status === "done" || s.status === "skipped");
|
||||
return !card.streaming || !!card.aborted;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
@@ -24,18 +22,24 @@ function isSettled(card: Card): boolean {
|
||||
|
||||
export function CardStream(): React.ReactElement {
|
||||
const cards = useAgentState((s) => s.cards);
|
||||
let cutoff = cards.length;
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
if (!isSettled(cards[i] as Card)) {
|
||||
cutoff = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const committed = cards.slice(0, cutoff);
|
||||
const live = cards.slice(cutoff);
|
||||
const lastIdx = cards.length - 1;
|
||||
const lastCard = lastIdx >= 0 ? (cards[lastIdx] as Card) : null;
|
||||
const lastIsLive = !!lastCard && !isSettled(lastCard);
|
||||
const committed: Card[] = lastIsLive ? cards.slice(0, lastIdx) : cards.slice();
|
||||
const live: Card[] = lastIsLive && lastCard ? [lastCard] : [];
|
||||
// Static items are emitted via bridge.emitStatic, which renders them in an
|
||||
// off-tree React reconciler — context from the live tree does NOT propagate.
|
||||
// The ActiveCardContext.Provider must therefore live inside the children
|
||||
// function so it travels with the rendered subtree.
|
||||
return (
|
||||
<>
|
||||
<Static items={committed}>{(card) => <CardRenderer key={card.id} card={card} />}</Static>
|
||||
<Static items={committed}>
|
||||
{(card) => (
|
||||
<ActiveCardContext.Provider value={false} key={card.id}>
|
||||
<CardRenderer card={card} />
|
||||
</ActiveCardContext.Provider>
|
||||
)}
|
||||
</Static>
|
||||
{live.map((card) => (
|
||||
<CardRenderer key={card.id} card={card} />
|
||||
))}
|
||||
|
||||
@@ -5,21 +5,25 @@ import type { ApplyResult } from "../../../code/edit-blocks.js";
|
||||
import type { EditMode } from "../../../config.js";
|
||||
import type { JobRegistry } from "../../../tools/jobs.js";
|
||||
import { CharBar } from "../char-bar.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pill.js";
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { useElapsedSeconds, useSlowTick, useTick } from "../ticker.js";
|
||||
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
/** "Thinking" row — circle spinner per design (model wait, not tool call). */
|
||||
/** "Thinking" row — soft pulse + italic label (model wait, not tool call). */
|
||||
export function ThinkingRow({ text }: { text: string }) {
|
||||
const elapsed = useElapsedSeconds();
|
||||
return (
|
||||
<Box marginY={1} paddingX={1}>
|
||||
<Spinner kind="circle" color={TONE.brand} bold />
|
||||
<Text>{" "}</Text>
|
||||
<Text color={TONE.brand}>{text}</Text>
|
||||
<Text color={FG.faint}>{` · ${elapsed}s`}</Text>
|
||||
<Box marginY={1} paddingX={1} gap={1}>
|
||||
<Spinner kind="circle" color={TONE.accent} />
|
||||
<Text italic color={FG.sub}>
|
||||
{text}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{`${elapsed}s`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -155,7 +159,7 @@ function subagentTitle(skillName: string | undefined, task: string): string {
|
||||
return `Sub-agent · ${short || "anonymous"}`;
|
||||
}
|
||||
|
||||
/** Live block for a running subagent. Fixed row count — never grows as inner events arrive, so the screen doesn't jump. */
|
||||
/** Live block for a running subagent — Card-styled, fixed row count for flicker-safety. */
|
||||
export function SubagentRow({
|
||||
activity,
|
||||
}: {
|
||||
@@ -169,49 +173,37 @@ export function SubagentRow({
|
||||
lastInner: { glyph: string; color: string; label: string; meta?: string } | null;
|
||||
};
|
||||
}) {
|
||||
const tick = useTick();
|
||||
useTick();
|
||||
const seconds = (activity.elapsedMs / 1000).toFixed(1);
|
||||
const phase = subagentPhaseLabel(activity.phase, activity.iter, activity.elapsedMs);
|
||||
const last = activity.lastInner;
|
||||
const subtitle = activity.skillName ?? truncate(activity.task, 48);
|
||||
const modelBadge = activity.model ? modelBadgeFor(activity.model) : null;
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={CARD.subagent.color}>{"▎ "}</Text>
|
||||
<Text bold color={CARD.subagent.color}>
|
||||
{`⌬ ${subagentTitle(activity.skillName, activity.task)}`}
|
||||
</Text>
|
||||
<Box flexGrow={1} />
|
||||
<Text color={CARD.subagent.color}>{`iter ${activity.iter} · ${seconds}s`}</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={CARD.subagent.color} bold>
|
||||
{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={CARD.subagent.color}>{"▎"}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={CARD.subagent.color}>{"▎ "}</Text>
|
||||
<Text color={FG.faint}>{"Task "}</Text>
|
||||
<Card tone={CARD.subagent.color}>
|
||||
<CardHeader
|
||||
glyph="⌬"
|
||||
tone={CARD.subagent.color}
|
||||
title="subagent"
|
||||
titleColor={PILL_SECTION.plan.fg}
|
||||
titleBg={PILL_SECTION.plan.bg}
|
||||
subtitle={subtitle}
|
||||
meta={[`iter ${activity.iter}`, `${seconds}s`]}
|
||||
right={
|
||||
<>
|
||||
{modelBadge ? (
|
||||
<Pill label={modelBadge.label} {...PILL_MODEL[modelBadge.kind]} bold={false} />
|
||||
) : null}
|
||||
<Spinner kind="braille" color={CARD.subagent.color} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Text color={FG.faint}>
|
||||
{"task "}
|
||||
<Text color={FG.sub}>{activity.task}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={CARD.subagent.color}>{"▎ "}</Text>
|
||||
<Text color={FG.faint}>{"Model "}</Text>
|
||||
<Text color={FG.sub}>{activity.model ?? "—"}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={CARD.subagent.color}>{"▎"}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={CARD.subagent.color}>{"▎ "}</Text>
|
||||
<Text color={FG.faint}>{"Last "}</Text>
|
||||
</Text>
|
||||
<Text color={FG.faint}>
|
||||
{"last "}
|
||||
{last ? (
|
||||
<>
|
||||
<Text color={last.color}>{`${last.glyph} `}</Text>
|
||||
@@ -219,25 +211,21 @@ export function SubagentRow({
|
||||
{last.meta ? <Text color={FG.faint}>{` ${last.meta}`}</Text> : null}
|
||||
</>
|
||||
) : (
|
||||
<Text color={FG.faint}>{"queued…"}</Text>
|
||||
<Text color={FG.faint}>queued…</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={CARD.subagent.color}>{"▎"}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={CARD.subagent.color}>{"▎ "}</Text>
|
||||
<Text bold color={TONE.brand}>
|
||||
{"▶ "}
|
||||
</Text>
|
||||
<Text color={TONE.brand}>{phase}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Text>
|
||||
<Text color={TONE.brand}>
|
||||
{"▶ "}
|
||||
{phase}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
/** Live spinner + arg summary while a tool call is in flight; absorbs MCP progress frames. */
|
||||
export function OngoingToolRow({
|
||||
tool,
|
||||
|
||||
@@ -4,25 +4,17 @@ import React from "react";
|
||||
import { Countdown } from "../primitives/Countdown.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
import type { Mode, NetworkState, StatusBar } from "../state/state.js";
|
||||
import { FG, TONE, USD_TO_CNY } from "../theme/tokens.js";
|
||||
import { FG, TONE, balanceColor, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const RULE_PAD = 4;
|
||||
const RULE_MIN = 20;
|
||||
|
||||
function balanceColor(cny: number): string {
|
||||
if (cny < 5) return TONE.err;
|
||||
if (cny < 20) return TONE.warn;
|
||||
return TONE.brand;
|
||||
}
|
||||
|
||||
export function StatusRow(): React.ReactElement {
|
||||
const status = useAgentState((s) => s.status);
|
||||
const session = useAgentState((s) => s.session);
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const ruleWidth = Math.max(RULE_MIN, cols - RULE_PAD);
|
||||
const turnCny = status.cost * USD_TO_CNY;
|
||||
const sessionCny = status.sessionCost * USD_TO_CNY;
|
||||
const hasTurn = status.cost > 0;
|
||||
|
||||
return (
|
||||
@@ -48,16 +40,22 @@ export function StatusRow(): React.ReactElement {
|
||||
<Text bold color={TONE.brand}>
|
||||
{"▸ "}
|
||||
</Text>
|
||||
<Text bold color={FG.body}>{`¥${turnCny.toFixed(4)} turn`}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{`${formatCost(status.cost, status.balanceCurrency)} turn`}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
<Text color={FG.sub}>{`¥${sessionCny.toFixed(3)} session`}</Text>
|
||||
<Text
|
||||
color={FG.sub}
|
||||
>{`${formatCost(status.sessionCost, status.balanceCurrency, 3)} session`}</Text>
|
||||
{status.balance !== undefined && (
|
||||
<>
|
||||
<Sep />
|
||||
<Text color={FG.faint}>{"wallet "}</Text>
|
||||
<Text bold color={balanceColor(status.balance)}>{`¥${status.balance.toFixed(2)}`}</Text>
|
||||
<Text bold color={balanceColor(status.balance, status.balanceCurrency)}>
|
||||
{formatBalance(status.balance, status.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { PlanCard } from "../cards/PlanCard.js";
|
||||
import type { Card, PlanCard as PlanCardData } from "../state/cards.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
|
||||
export function isActivePlanInFlight(card: Card): boolean {
|
||||
if (card.kind !== "plan") return false;
|
||||
if (card.variant !== "active") return false;
|
||||
return !card.steps.every((s) => s.status === "done" || s.status === "skipped");
|
||||
}
|
||||
|
||||
export function PlanLiveRow(): React.ReactElement | null {
|
||||
const planCard = useAgentState((s) => {
|
||||
for (let i = s.cards.length - 1; i >= 0; i--) {
|
||||
const c = s.cards[i] as Card;
|
||||
if (isActivePlanInFlight(c)) return c as PlanCardData;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (!planCard) return null;
|
||||
return <PlanCard card={planCard} />;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import stringWidth from "string-width";
|
||||
import { wrapToCells } from "../../frame/width.js";
|
||||
import { FG, SURFACE, TONE } from "./theme/tokens.js";
|
||||
|
||||
/** Left margin consumed by CardBox (marginLeft=2 + borderLeft=1) + body paddingLeft. */
|
||||
/** Left margin consumed by card outer marginLeft + body paddingLeft + safety. */
|
||||
const BODY_LEFT_CELLS = 7;
|
||||
|
||||
const MarkdownWidthCtx = React.createContext<number | undefined>(undefined);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Applies an MCP append-drift mid-session: registers each new tool in the loop's registry + prefix, and updates the summary's report. */
|
||||
/** Applies an MCP append-drift mid-session: registers each new tool in the loop's registry + prefix, and returns an updated summary. Immutable — does not mutate the input `target`. */
|
||||
|
||||
import type { CacheFirstLoop } from "../../loop.js";
|
||||
import { registerSingleMcpTool } from "../../mcp/registry.js";
|
||||
@@ -10,7 +10,7 @@ export function applyMcpAppend(
|
||||
loop: CacheFirstLoop,
|
||||
target: McpServerSummary,
|
||||
addedTools: McpTool[],
|
||||
): void {
|
||||
): McpServerSummary {
|
||||
const accepted: McpTool[] = [];
|
||||
for (const mcpTool of addedTools) {
|
||||
if (!mcpTool.name) continue;
|
||||
@@ -27,14 +27,18 @@ export function applyMcpAppend(
|
||||
loop.prefix.addTool(spec);
|
||||
accepted.push(mcpTool);
|
||||
}
|
||||
if (accepted.length === 0) return;
|
||||
// Refresh the summary's snapshot so `/mcp` and the browser modal show the
|
||||
// new shape on their next render.
|
||||
if (target.report.tools.supported) {
|
||||
const merged = [...target.report.tools.items, ...accepted];
|
||||
// biome-ignore lint/suspicious/noExplicitAny: report is a typed snapshot we mutate in place; deeper refactor isn't worth it here
|
||||
(target.report.tools as any).items = merged;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: same — toolCount mirrors items.length post-append
|
||||
(target as any).toolCount = merged.length;
|
||||
}
|
||||
if (accepted.length === 0 || !target.report.tools.supported) return target;
|
||||
|
||||
const merged = [...target.report.tools.items, ...accepted];
|
||||
return {
|
||||
...target,
|
||||
toolCount: merged.length,
|
||||
report: {
|
||||
...target.report,
|
||||
tools: {
|
||||
supported: true as const,
|
||||
items: merged,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/** `/resource` + `/prompt` handlers — async (round-trip to MCP server), so App.tsx calls directly instead of `handleSlash`. */
|
||||
|
||||
import type { McpClient } from "../../mcp/client.js";
|
||||
import type {
|
||||
GetPromptResult,
|
||||
McpPromptMessage,
|
||||
@@ -161,9 +160,8 @@ export async function handleMcpBrowseSlash(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const client: McpClient = server.host.client;
|
||||
try {
|
||||
const result = await client.readResource(arg);
|
||||
const result = await server.readResource(arg);
|
||||
log.pushInfo(formatResourceContents(arg, result));
|
||||
} catch (err) {
|
||||
log.pushWarning("readResource failed", (err as Error).message);
|
||||
@@ -180,9 +178,8 @@ export async function handleMcpBrowseSlash(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const client: McpClient = server.host.client;
|
||||
try {
|
||||
const result = await client.getPrompt(arg);
|
||||
const result = await server.getPrompt(arg);
|
||||
log.pushInfo(formatPromptMessages(arg, result));
|
||||
} catch (err) {
|
||||
log.pushWarning("getPrompt failed", (err as Error).message);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { COLOR } from "./theme.js";
|
||||
|
||||
export interface HealthBadge {
|
||||
glyph: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function healthBadge(elapsedMs: number): HealthBadge {
|
||||
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 };
|
||||
}
|
||||
|
||||
// Preserves original slash thresholds: 0 → "● healthy · 0ms" (no === 0 branch)
|
||||
export function slashHealthBadge(elapsedMs: number): string {
|
||||
if (elapsedMs < 500) return `● healthy · ${elapsedMs}ms`;
|
||||
if (elapsedMs < 3000) return `◌ slow · ${elapsedMs}ms`;
|
||||
return `✗ very slow · ${elapsedMs}ms`;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import type { McpTool } from "../../mcp/types.js";
|
||||
import { formatMcpLifecycleEvent } from "./mcp-lifecycle.js";
|
||||
import type { McpServerSummary } from "./slash/types.js";
|
||||
|
||||
/** Applies append-drift mid-session: registers each new MCP tool in the registry + prefix. */
|
||||
export type ApplyAppend = (target: McpServerSummary, addedTools: McpTool[]) => void;
|
||||
/** Applies append-drift mid-session: registers each new MCP tool in the registry + prefix. Returns the updated summary. */
|
||||
export type ApplyAppend = (target: McpServerSummary, addedTools: McpTool[]) => McpServerSummary;
|
||||
|
||||
/** Kicks off async reconnect; returns the start-line, schedules result via postInfo. */
|
||||
export function kickOffMcpReconnect(
|
||||
@@ -18,17 +18,20 @@ export function kickOffMcpReconnect(
|
||||
// Only opt into "append" when the caller wired an applyAppend handler;
|
||||
// otherwise the reconnect refuses append-drift with a "restart" message.
|
||||
const accept = applyAppend ? (["identity", "append"] as const) : (["identity"] as const);
|
||||
// Use a mutable local so the async closure can update it after applyAppend
|
||||
// without reassigning the function parameter (linter: noParameterAssign).
|
||||
let liveTarget = target;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await reconnectMcpServer({
|
||||
host: target.host,
|
||||
spec: target.spec,
|
||||
host: liveTarget.host,
|
||||
spec: liveTarget.spec,
|
||||
beforeTools,
|
||||
accept,
|
||||
});
|
||||
if (result.ok) {
|
||||
if (result.kind === "append" && applyAppend) {
|
||||
applyAppend(target, result.addedTools);
|
||||
liveTarget = applyAppend(liveTarget, result.addedTools);
|
||||
}
|
||||
postInfo(
|
||||
formatMcpLifecycleEvent({
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { McpServerSummary } from "./slash/types.js";
|
||||
|
||||
export function sameMcpServerSummary(a: McpServerSummary, b: McpServerSummary): boolean {
|
||||
return a === b || (a.label === b.label && a.spec === b.spec);
|
||||
}
|
||||
|
||||
export function replaceMcpServerSummary(
|
||||
servers: McpServerSummary[],
|
||||
target: McpServerSummary,
|
||||
updated: McpServerSummary,
|
||||
): McpServerSummary[] {
|
||||
return servers.map((server) => (sameMcpServerSummary(server, target) ? updated : server));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Box } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useContext } from "react";
|
||||
|
||||
/** Settled cards (in scrollback) drop the border + paddingX so history reads as flat lines. */
|
||||
export const ActiveCardContext = React.createContext(true);
|
||||
|
||||
export interface CardProps {
|
||||
tone: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const STRIPE_BORDER = {
|
||||
topLeft: " ",
|
||||
top: " ",
|
||||
topRight: " ",
|
||||
left: "▎",
|
||||
right: " ",
|
||||
bottomLeft: " ",
|
||||
bottom: " ",
|
||||
bottomRight: " ",
|
||||
} as const;
|
||||
|
||||
export function Card({ tone, children }: CardProps): React.ReactElement {
|
||||
const active = useContext(ActiveCardContext);
|
||||
if (!active) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle={STRIPE_BORDER}
|
||||
borderColor={tone}
|
||||
borderTop={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
paddingLeft={1}
|
||||
marginTop={1}
|
||||
width="100%"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/** Wraps card content in a Box with `▎` rendered as a per-row borderLeft, so wrap continuations of long markdown / output lines still show the accent bar. */
|
||||
|
||||
import { Box } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
|
||||
const BAR_STYLE = {
|
||||
topLeft: " ",
|
||||
top: " ",
|
||||
topRight: " ",
|
||||
right: " ",
|
||||
bottomRight: " ",
|
||||
bottom: " ",
|
||||
bottomLeft: " ",
|
||||
left: "▎",
|
||||
} as const;
|
||||
|
||||
export function CardBox({
|
||||
color,
|
||||
children,
|
||||
}: {
|
||||
color: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle={BAR_STYLE}
|
||||
borderTop={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
borderLeft
|
||||
borderLeftColor={color}
|
||||
flexGrow={1}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
|
||||
export type MetaItem = string | { text: string; color: string };
|
||||
|
||||
export interface CardHeaderProps {
|
||||
glyph: string;
|
||||
tone: string;
|
||||
title: string;
|
||||
/** Override the default tone-colored bold title (e.g. demoted cards use FG.sub). */
|
||||
titleColor?: string;
|
||||
/** When set, render the title as a backgrounded pill (e.g. `▎ ◆ reasoning ` with a tinted block). */
|
||||
titleBg?: string;
|
||||
/** Body-tone text after the title, separated by a space (no `·`). */
|
||||
subtitle?: string;
|
||||
/** Faint trailing fields, prefixed with ` · ` and joined by ` · `. */
|
||||
meta?: ReadonlyArray<MetaItem>;
|
||||
/** Inline ad-hoc element after meta — for spinners, badges, anything outside the meta vocabulary. */
|
||||
right?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
glyph,
|
||||
tone,
|
||||
title,
|
||||
titleColor,
|
||||
titleBg,
|
||||
subtitle,
|
||||
meta,
|
||||
right,
|
||||
}: CardHeaderProps): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={tone}>{glyph}</Text>
|
||||
{titleBg ? (
|
||||
<Text backgroundColor={titleBg} color={titleColor ?? tone} bold>
|
||||
{` ${title} `}
|
||||
</Text>
|
||||
) : (
|
||||
<Text bold color={titleColor ?? tone}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{subtitle ? <Text color={FG.body}>{subtitle}</Text> : null}
|
||||
{meta?.map((item, i) => {
|
||||
const isStr = typeof item === "string";
|
||||
const text = isStr ? item : item.text;
|
||||
const color = isStr ? FG.faint : item.color;
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: meta items are positional
|
||||
<React.Fragment key={`m-${i}`}>
|
||||
<Text color={FG.faint}>·</Text>
|
||||
<Text color={color}>{text}</Text>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{right}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,10 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
argCompleter: ["EN", "zh-CN"],
|
||||
},
|
||||
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
|
||||
{
|
||||
cmd: "mcp browse",
|
||||
summary: "browse the MCP marketplace — search registry, install with Enter",
|
||||
},
|
||||
{
|
||||
cmd: "resource",
|
||||
argsHint: "[uri]",
|
||||
@@ -119,12 +123,12 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
{ cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
|
||||
{
|
||||
cmd: "compact",
|
||||
argsHint: "[tokens]",
|
||||
summary:
|
||||
"shrink oversized tool results AND tool-call args (edit_file search/replace) in the log; cap in tokens, default 4000",
|
||||
"fold older turns into a summary message (cache-safe). Auto-fires at 50% ctx; this is the manual trigger.",
|
||||
},
|
||||
{ cmd: "keys", summary: "show all keyboard shortcuts and prompt prefixes" },
|
||||
{ cmd: "plans", summary: "list this session's active + archived plans, newest first" },
|
||||
{ cmd: "stop", summary: "abort the current model turn (typed alternative to Esc)" },
|
||||
{
|
||||
cmd: "replay",
|
||||
summary: "load an archived plan as a read-only Time Travel snapshot (default: newest)",
|
||||
@@ -138,12 +142,6 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
summary: "show the launch command to resume a saved session",
|
||||
},
|
||||
{ cmd: "forget", summary: "delete the current session from disk" },
|
||||
{
|
||||
cmd: "prune-sessions",
|
||||
summary: "delete sessions idle ≥N days (default 90) — frees disk on long-time installs",
|
||||
argsHint: "[days]",
|
||||
},
|
||||
{ cmd: "setup", summary: "reminds you to exit and run `reasonix setup`" },
|
||||
{
|
||||
cmd: "semantic",
|
||||
summary: "show semantic_search status — built? Ollama installed? how to enable",
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
type ResolvedHook,
|
||||
globalSettingsPath,
|
||||
projectSettingsPath,
|
||||
} from "../../../../hooks.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "../../../../telemetry/usage.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "../../../../version.js";
|
||||
} from "@/hooks.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "@/telemetry/usage.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "@/version.js";
|
||||
import { runDoctorChecks } from "../../../commands/doctor.js";
|
||||
import { renderDashboard } from "../../../commands/stats.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
@@ -77,7 +77,6 @@ const help: SlashHandler = () => ({
|
||||
t("handlers.basic.helpMcp"),
|
||||
t("handlers.basic.helpResource"),
|
||||
t("handlers.basic.helpPrompt"),
|
||||
t("handlers.basic.helpSetup"),
|
||||
t("handlers.basic.helpCompact"),
|
||||
t("handlers.basic.helpThink"),
|
||||
t("handlers.basic.helpTool"),
|
||||
@@ -147,10 +146,6 @@ const help: SlashHandler = () => ({
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const setup: SlashHandler = () => ({
|
||||
info: t("handlers.basic.setupInfo"),
|
||||
});
|
||||
|
||||
const retry: SlashHandler = (_args, loop) => {
|
||||
const prev = loop.retryLastUser();
|
||||
if (!prev) {
|
||||
@@ -201,7 +196,6 @@ export const handlers: Record<string, SlashHandler> = {
|
||||
keys,
|
||||
help,
|
||||
"?": help,
|
||||
setup,
|
||||
retry,
|
||||
loop,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const dashboard: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
fmtAgo,
|
||||
listCheckpoints,
|
||||
restoreCheckpoint,
|
||||
} from "../../../../code/checkpoints.js";
|
||||
import type { EditMode } from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
} from "@/code/checkpoints.js";
|
||||
import type { EditMode } from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { parseEditIndices } from "../../edit-history.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { runGitCommit, stripOuterQuotes } from "../helpers.js";
|
||||
|
||||