mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcfb7e586d | ||
|
|
122d2e0fc9 | ||
|
|
cf122c357d | ||
|
|
de4b342199 | ||
|
|
6761a31697 | ||
|
|
94dc1926f2 | ||
|
|
98183baf99 | ||
|
|
614236eb03 | ||
|
|
b1efcfe091 | ||
|
|
c2966f5c0e | ||
|
|
4dbb03ae36 | ||
|
|
05491f16c6 | ||
|
|
e2b3de7246 | ||
|
|
d9cfeffd50 | ||
|
|
67a1d6194b | ||
|
|
0a1c65d9db | ||
|
|
ef2b50d873 | ||
|
|
6cde945a01 | ||
|
|
faf59cee4a | ||
|
|
1e7be37a55 | ||
|
|
7f54ae0678 | ||
|
|
bdf8faeb4e | ||
|
|
a9c407853a | ||
|
|
959e9ffefd | ||
|
|
012757567e | ||
|
|
453a3bc9d3 | ||
|
|
dbafd1632c | ||
|
|
1aca86b424 | ||
|
|
5ce642aeeb | ||
|
|
c888ab33d7 | ||
|
|
88514595ac | ||
|
|
29020327fe | ||
|
|
7642ad6ba2 | ||
|
|
e205c59775 | ||
|
|
874f0d1cc1 | ||
|
|
2462b1c278 | ||
|
|
d421970a27 | ||
|
|
d1f491b29f | ||
|
|
3f6fb51b08 | ||
|
|
c63239ddb8 | ||
|
|
ec5bfabde2 | ||
|
|
a525182aff | ||
|
|
f795583cea | ||
|
|
bb971bd403 | ||
|
|
06204c760e | ||
|
|
c504707af8 | ||
|
|
f0a552bed5 | ||
|
|
bb5b124817 | ||
|
|
636ad7e551 | ||
|
|
1c360ac81b | ||
|
|
c120ed3a39 | ||
|
|
42f75c7478 | ||
|
|
34b8275f20 | ||
|
|
b40db0f469 | ||
|
|
4029b7dc25 | ||
|
|
9c69513650 | ||
|
|
df8f146b79 | ||
|
|
e6b247939c | ||
|
|
424a9301f4 | ||
|
|
b171848250 | ||
|
|
cf393f7f9e | ||
|
|
771443c670 | ||
|
|
747651e55d | ||
|
|
d4a4c599b5 | ||
|
|
192a63b8d2 | ||
|
|
58f4544d23 | ||
|
|
76e42ac665 | ||
|
|
a9471ae8e1 | ||
|
|
f9a1d0912d | ||
|
|
3f3b5e2fe7 | ||
|
|
0990491584 | ||
|
|
40628d0025 | ||
|
|
346bb35cb8 | ||
|
|
97a93cf4f5 | ||
|
|
d837ddaf43 | ||
|
|
c56cd9d9a0 | ||
|
|
db194cb833 | ||
|
|
d817f1b965 | ||
|
|
9ea76a3ef9 | ||
|
|
136ed9ff58 | ||
|
|
5dbcd1b73b | ||
|
|
447d1d7049 | ||
|
|
042d2c4f6a | ||
|
|
f8143f7098 | ||
|
|
6fcf3a6cba | ||
|
|
1934e2598c | ||
|
|
03804e6170 | ||
|
|
b3f0aee657 | ||
|
|
1ffa27975c | ||
|
|
d63509ad85 | ||
|
|
459a370b7c | ||
|
|
39e69c2cab | ||
|
|
0d89ab84ef | ||
|
|
ecb1bfb051 | ||
|
|
db2ca97eb0 | ||
|
|
47f207119f | ||
|
|
6ccca2be0b | ||
|
|
f422a94dfa | ||
|
|
ee5961fe70 | ||
|
|
15b4060e68 | ||
|
|
9ec5b99e07 | ||
|
|
e93c4ffde3 | ||
|
|
0e4cba3670 | ||
|
|
d0b251d43a | ||
|
|
daebf3d571 | ||
|
|
28119cad01 | ||
|
|
3c24c0f1e1 | ||
|
|
7edcb07465 | ||
|
|
04b12e9f2d | ||
|
|
b07b86ee2f | ||
|
|
7ba3d086ff | ||
|
|
1a7d74f3f0 | ||
|
|
0f7dcd026a | ||
|
|
12998d246d | ||
|
|
43d437fd38 | ||
|
|
62813e3d6d | ||
|
|
a29a0ac157 | ||
|
|
ccdbfb3a28 | ||
|
|
1805b0c647 | ||
|
|
4032930fed | ||
|
|
b03e11bf41 | ||
|
|
0b91250c44 | ||
|
|
5bcc3b6389 | ||
|
|
0f7f7c24c2 | ||
|
|
6379bc0628 | ||
|
|
82c5627123 | ||
|
|
e991644416 | ||
|
|
39dbe19bcd | ||
|
|
c1e8a933d3 | ||
|
|
b401913910 | ||
|
|
5662b57a4b | ||
|
|
e40e1911cc | ||
|
|
fdea3bb837 | ||
|
|
3efa4cc374 | ||
|
|
26ca84dd8f | ||
|
|
e91d136dbc | ||
|
|
12a3dab7e9 | ||
|
|
deefafed10 | ||
|
|
882a7ad2d9 | ||
|
|
b248a87455 | ||
|
|
ff858ed744 | ||
|
|
a5adca41ac | ||
|
|
aed4d431cb | ||
|
|
0acfcefb89 | ||
|
|
9cb0bdbc5f | ||
|
|
2ca403670d | ||
|
|
c6e5bf4b02 | ||
|
|
08c7f2be81 | ||
|
|
8befb44e5e | ||
|
|
b65ed747c9 | ||
|
|
86be00299e | ||
|
|
b634b5e171 | ||
|
|
3e91082456 | ||
|
|
638eaeeb46 | ||
|
|
1a7b88c72f | ||
|
|
6ee2428802 | ||
|
|
febecb3d6f | ||
|
|
795a1e2f35 | ||
|
|
77cd4f7f66 | ||
|
|
3ec46356f5 | ||
|
|
825c6e9608 | ||
|
|
affa03b235 | ||
|
|
4ba0199c42 | ||
|
|
5f80b252e8 | ||
|
|
4b77671103 | ||
|
|
900eae2a3a | ||
|
|
94047eadf3 | ||
|
|
0945a1189e | ||
|
|
bba8199ba4 |
No files matched your search
@@ -4,6 +4,10 @@ about: Something is broken
|
||||
labels: bug
|
||||
---
|
||||
|
||||
> **Screen flicker, garbled output, leftover artifacts, cursor jumping?**
|
||||
> Use the **Display / rendering issue** template instead — it asks for the
|
||||
> terminal-specific info we need to diagnose those.
|
||||
|
||||
**What happened**
|
||||
A clear and concise description.
|
||||
|
||||
@@ -14,10 +18,13 @@ What you expected to happen.
|
||||
Steps or minimal code that reproduces it.
|
||||
|
||||
**Environment**
|
||||
- Reasonix version:
|
||||
- Node version:
|
||||
- OS:
|
||||
- DeepSeek model (e.g. `deepseek-chat`, `deepseek-reasoner`):
|
||||
- Reasonix version (`reasonix --version`):
|
||||
- Node version (`node --version`):
|
||||
- OS (Windows 11 / macOS 14 / Ubuntu 24.04 / …):
|
||||
- Shell (bash, zsh, fish, PowerShell 7, PowerShell 5.1, cmd, …):
|
||||
- Terminal app (Windows Terminal, iTerm2, Alacritty, kitty, WezTerm, **VSCode integrated**, **Cursor integrated**, Hyper, …):
|
||||
- DeepSeek model (e.g. `deepseek-v4-flash`, `deepseek-v4-pro`):
|
||||
|
||||
**Logs / transcript**
|
||||
If using the CLI, attach the relevant chunk of `--transcript`.
|
||||
If using the CLI, attach the relevant chunk of `--transcript`, or run
|
||||
`reasonix doctor` and paste the output.
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: Display / rendering issue
|
||||
about: Screen flicker, garbled output, leftover artifacts, cursor jumping
|
||||
labels: bug, rendering
|
||||
---
|
||||
|
||||
> Display problems almost always come from the **terminal emulator**, not
|
||||
> the shell. Please fill the terminal section carefully — `bash vs PowerShell`
|
||||
> tells us very little; `VSCode integrated terminal vs Windows Terminal`
|
||||
> tells us everything.
|
||||
|
||||
**Symptom** (tick all that apply)
|
||||
- [ ] Whole screen flickers / flashes during streaming response
|
||||
- [ ] Lines tear or only half-redraw
|
||||
- [ ] Stale output left behind after a frame updates
|
||||
- [ ] Cursor jumps to wrong column or vanishes
|
||||
- [ ] Mojibake / wrong-width characters (e.g. `□`, half-width emoji)
|
||||
- [ ] Other (describe below)
|
||||
|
||||
**When it happens**
|
||||
- [ ] During assistant streaming (token-by-token output)
|
||||
- [ ] When tool cards expand / collapse
|
||||
- [ ] During scroll-up / scrollback
|
||||
- [ ] On terminal resize
|
||||
- [ ] On launch / on quit
|
||||
- [ ] Other (describe below)
|
||||
|
||||
**Terminal — the important part**
|
||||
|
||||
Where exactly are you running `reasonix`?
|
||||
|
||||
- [ ] **VSCode** integrated terminal — VSCode version: `?`
|
||||
- [ ] **Cursor** integrated terminal — Cursor version: `?`
|
||||
- [ ] **Windows Terminal** — version: `?`
|
||||
- [ ] **cmd.exe** (legacy console host)
|
||||
- [ ] **PowerShell ISE** (note: ISE doesn't support ANSI — most things will look broken)
|
||||
- [ ] **iTerm2** / **Terminal.app** / **Alacritty** / **kitty** / **WezTerm** / **Hyper**
|
||||
- [ ] tmux / screen / mosh — and inside which outer terminal? `?`
|
||||
- [ ] Other:
|
||||
|
||||
> 💡 **How to find your VSCode / Cursor version**
|
||||
> `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS).
|
||||
> Paste the whole panel — version + commit + Electron + xterm.js if shown.
|
||||
|
||||
**Diagnostic dump — copy/paste output**
|
||||
|
||||
Run **one** of the snippets below in the same terminal where you saw the
|
||||
issue, and paste the output here:
|
||||
|
||||
<details><summary>PowerShell (Windows)</summary>
|
||||
|
||||
```powershell
|
||||
reasonix --version; node --version
|
||||
$PSVersionTable.PSVersion.ToString()
|
||||
[System.Environment]::OSVersion.VersionString
|
||||
"TERM=$env:TERM"
|
||||
"TERM_PROGRAM=$env:TERM_PROGRAM"
|
||||
"TERM_PROGRAM_VERSION=$env:TERM_PROGRAM_VERSION"
|
||||
"COLORTERM=$env:COLORTERM"
|
||||
"WT_SESSION=$env:WT_SESSION"
|
||||
"VSCODE_INJECTION=$env:VSCODE_INJECTION"
|
||||
"WSL_DISTRO_NAME=$env:WSL_DISTRO_NAME"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>bash / zsh (macOS / Linux / WSL / Git Bash)</summary>
|
||||
|
||||
```bash
|
||||
reasonix --version; node --version
|
||||
uname -a
|
||||
echo "TERM=$TERM"
|
||||
echo "TERM_PROGRAM=$TERM_PROGRAM"
|
||||
echo "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION"
|
||||
echo "COLORTERM=$COLORTERM"
|
||||
echo "WT_SESSION=$WT_SESSION"
|
||||
echo "VSCODE_INJECTION=$VSCODE_INJECTION"
|
||||
echo "WSL_DISTRO_NAME=$WSL_DISTRO_NAME"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
```
|
||||
<paste output here>
|
||||
```
|
||||
|
||||
**VSCode / Cursor users only — terminal settings**
|
||||
|
||||
Open Settings (`Ctrl+,`), search `terminal.integrated.gpuAcceleration`,
|
||||
report current value: `auto` / `on` / `canvas` / `off` — `?`
|
||||
|
||||
Already tried any of:
|
||||
- [ ] Switching `gpuAcceleration` to a different value
|
||||
- [ ] Detaching the terminal (drag tab into its own window)
|
||||
- [ ] Running the same command in a non-VSCode terminal — did it still flicker? `yes / no`
|
||||
|
||||
**Reproduction**
|
||||
|
||||
Steps that reliably trigger it (commands run, files edited, was a tool
|
||||
streaming a long response, was the window being resized, …):
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Screen recording (strongly preferred)**
|
||||
|
||||
A 5–10s GIF or MP4 is worth 1000 words for rendering bugs. Drop it in
|
||||
this comment box — GitHub uploads attachments inline.
|
||||
@@ -48,6 +48,3 @@ jobs:
|
||||
# DEEPSEEK_API_KEY in CI.
|
||||
- name: τ-bench harness dry-run
|
||||
run: npx tsx benchmarks/tau-bench/runner.ts --dry --out /tmp/tau-dry.json
|
||||
|
||||
- name: Harvest-bench harness dry-run
|
||||
run: npx tsx benchmarks/harvest/runner.ts --dry --out /tmp/harvest-dry.json
|
||||
@@ -33,4 +33,6 @@ sessions/
|
||||
.reasonix/semantic-skip
|
||||
# Scratch entry regenerated each time scripts/bundle-codemirror.mjs runs.
|
||||
scripts/.cm-entry.mjs
|
||||
# Personal bun lockfile — project uses npm officially.
|
||||
bun.lock
|
||||
|
||||
+1362
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,7 @@
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/reasonix/">Website</a>
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">Architecture</a>
|
||||
·
|
||||
@@ -41,6 +41,52 @@
|
||||
> [!TIP]
|
||||
> **Cache stability isn't a feature you turn on; it's an invariant the loop is designed around.** That's the whole reason Reasonix is DeepSeek-only — every layer is tuned to the byte-stable prefix-cache mechanic.
|
||||
|
||||
> [!NOTE]
|
||||
> **Real user, single day (2026-05-01):** 435M input tokens, **99.82% cache hit**, ~$12 instead of the ~$61 the same workload would cost with no cache on `v4-flash` — see the [case study](./benchmarks/real-world-cache/README.md). DeepSeek provides the cacheable bytes; the four mechanisms in [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) are how Reasonix keeps them cacheable across long sessions.
|
||||
|
||||
<br/>
|
||||
|
||||
## Web search
|
||||
|
||||
Reasonix includes `web_search` and `web_fetch` tools. By default it uses **Mojeek** (no setup required). You can switch to a **self-hosted SearXNG** instance — a metasearch engine that aggregates whatever upstream engines your instance is configured for.
|
||||
|
||||
### Switching engines (persists to disk)
|
||||
|
||||
The `/search-engine` slash command (alias `/se`) writes your choice to `~/.reasonix/config.json` immediately — it survives restarts:
|
||||
|
||||
```
|
||||
/search-engine mojeek # default, no external deps
|
||||
/search-engine searxng # SearXNG at http://localhost:8080
|
||||
/search-engine searxng http://192.168.1.100:8888 # custom endpoint
|
||||
```
|
||||
|
||||
Equivalent `~/.reasonix/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"webSearchEngine": "searxng",
|
||||
"webSearchEndpoint": "http://localhost:8080"
|
||||
}
|
||||
```
|
||||
|
||||
The tool picks up the change on the next call — no restart needed.
|
||||
|
||||
### Starting SearXNG
|
||||
|
||||
```sh
|
||||
podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng
|
||||
# or: docker run -d -p 8080:8080 searxng/searxng
|
||||
```
|
||||
|
||||
Verify it's running:
|
||||
|
||||
```sh
|
||||
curl http://localhost:8080/search?q=test
|
||||
# → HTML search results page
|
||||
```
|
||||
|
||||
> **Note:** The endpoint must include the protocol (`http://`). `localhost:8080` alone will fail — the tool will show a clear error telling you to install SearXNG if the server is unreachable.
|
||||
|
||||
<br/>
|
||||
|
||||
## Install
|
||||
@@ -52,19 +98,63 @@ npx reasonix code # paste a DeepSeek API key on first run; persists after
|
||||
|
||||
Requires Node ≥ 22. Tested on macOS · Linux · Windows (PowerShell · Git Bash · Windows Terminal). Get a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
|
||||
|
||||
`npx` is the recommended path — no global install, always picks up the latest version. If you'll use Reasonix daily and want `reasonix` on your `PATH`, run `reasonix update` once and it'll do the `npm install -g` for you.
|
||||
|
||||
### Subcommand cheatsheet
|
||||
|
||||
| Command | When to use |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | Coding agent rooted at a project. **Start here.** |
|
||||
| `reasonix chat` | Plain chat — no filesystem tools, just a conversation with persisted history. |
|
||||
| `reasonix run "task"` | One-shot, streams the answer to stdout. Good for shell pipes. |
|
||||
| `reasonix doctor` | Environment health check (Node version, API key, MCP wiring). |
|
||||
| `reasonix update` | Upgrade Reasonix itself. |
|
||||
|
||||
Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`) are listed in `reasonix --help` and on the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
|
||||
|
||||
#### When to pick `chat` over `code`
|
||||
|
||||
`code` is the default — it's the only mode that can read, write, or edit files, run shell commands, or apply SEARCH/REPLACE blocks. `chat` strips all of that down to a plain conversation; reach for it when you want a thinking-partner shell without granting filesystem or shell access.
|
||||
|
||||
| What you get | `reasonix code` | `reasonix chat` |
|
||||
|---|---|---|
|
||||
| Native filesystem tools (read · write · `edit_file`) | ✓ | — |
|
||||
| SEARCH/REPLACE edit blocks → `/apply` review | ✓ | — |
|
||||
| Shell tool (with confirm gate, `/mode yolo` to skip) | ✓ | — |
|
||||
| Plan mode · `submit_plan` · `/todo` · `/skill new` · `/mcp add` scaffolding | ✓ | — |
|
||||
| Memory tools (`remember` / `recall_memory`) | project + global | global only |
|
||||
| Web search · `ask_choice` · MCP servers from config | ✓ | ✓ |
|
||||
| Coding-focused system prompt (SEARCH/REPLACE, repo etiquette) | ✓ | generic |
|
||||
| Session scope | per-directory (`code-<basename>`) | shared default |
|
||||
|
||||
`chat` still picks up MCP servers from `~/.reasonix/config.json`, so if all you want is a chat shell with one or two MCP integrations, it's the lighter entrypoint. Otherwise `code` is what every screenshot, benchmark, and slash-command example in the docs assumes.
|
||||
|
||||
**Working in a different folder:** Reasonix scopes filesystem tools to the launch directory. To work elsewhere, pass `--dir`:
|
||||
|
||||
```bash
|
||||
npx reasonix code --dir /path/to/project # or use a relative path
|
||||
```
|
||||
|
||||
Mid-session switching isn't supported by design (the message log + memory paths get tangled with stale roots). Quit and relaunch with a new `--dir` to retarget. `/status` always shows the current pinned workspace.
|
||||
|
||||
**Author your first skill:** Skills are markdown playbooks the model can invoke (`/skill <name>`). There's no remote registry yet — you author them directly:
|
||||
|
||||
```bash
|
||||
/skill new my-skill # scaffolds <project>/.reasonix/skills/my-skill.md
|
||||
/skill new my-skill --global # or under ~/.reasonix/skills for cross-project use
|
||||
```
|
||||
|
||||
Edit the file (`description:` frontmatter + body), then `/skill list` to see it. Add `runAs: subagent` to the frontmatter to spawn an isolated subagent loop instead of inlining the body.
|
||||
|
||||
<br/>
|
||||
|
||||
## What makes Reasonix different
|
||||
|
||||
The loop is organized around four pillars. Each one solves a problem generic agent frameworks don't even see — because they were designed for a different cache mechanic.
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
The loop is organized around three pillars. Each one solves a problem generic agent frameworks don't even see — because they were designed for a different cache mechanic.
|
||||
|
||||
<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)
|
||||
Click through to the full architecture writeup → [Pillar 1 — Cache-first loop](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2 — Tool-call repair](./docs/ARCHITECTURE.md#pillar-2--tool-call-repair) · [Pillar 3 — Cost control](./docs/ARCHITECTURE.md#pillar-3--cost-control-v06)
|
||||
|
||||
</sub>
|
||||
|
||||
@@ -87,8 +177,10 @@ Click any card to read the full architecture writeup → [Pillar 1](./docs/ARCHI
|
||||
| Cost profile | **low per task** | premium | subscription + use | varies |
|
||||
| DeepSeek prefix-cache | **engineered** | not applicable | not applicable | incidental |
|
||||
| Embedded web dashboard | yes | — | n/a (IDE) | — |
|
||||
| Configurable web search engine | `/search-engine` | — | — | — |
|
||||
| Persistent per-workspace sessions | yes | partial | n/a | — |
|
||||
| Plan mode · MCP · hooks · skills | yes | yes | yes | partial |
|
||||
| Web search (Mojeek + SearXNG) | yes | yes | yes | yes |
|
||||
| Open community development | yes | — | — | yes |
|
||||
|
||||
For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmarks/) — the numbers move with model pricing, so they live with the harness, not in the README.
|
||||
@@ -97,9 +189,9 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
|
||||
|
||||
## Documentation
|
||||
|
||||
- [**Architecture**](./docs/ARCHITECTURE.md) — the four pillars, cache-first loop, harvesting, scaffolds
|
||||
- [**Architecture**](./docs/ARCHITECTURE.md) — three pillars: cache-first loop, tool-call repair, cost control
|
||||
- [**Benchmarks**](./benchmarks/) — τ-bench-lite harness, transcripts, cost methodology
|
||||
- [**Website**](https://esengine.github.io/reasonix/) — getting started, dashboard mockup, TUI mockup
|
||||
- [**Website**](https://esengine.github.io/DeepSeek-Reasonix/) — getting started, dashboard mockup, TUI mockup
|
||||
- [**Contributing**](./CONTRIBUTING.md) — comment policy, error-handling rules, library-over-hand-rolled
|
||||
- [**Code of Conduct**](./CODE_OF_CONDUCT.md) · [**Security policy**](./SECURITY.md)
|
||||
|
||||
@@ -115,7 +207,7 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
|
||||
**Open Discussions — opinions wanted:**
|
||||
|
||||
- [#20 · CLI / TUI design](https://github.com/esengine/reasonix/discussions/20) — what's broken, what's missing, what would you change?
|
||||
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/reasonix/design/agent-dashboard.html)
|
||||
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)
|
||||
- [#22 · Future feature wishlist](https://github.com/esengine/reasonix/discussions/22) — what would you build into Reasonix next?
|
||||
|
||||
**Already using Reasonix and willing to help others discover it?** Publish blog posts, articles, screenshots, talks, or videos to [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell). The project has no marketing budget — community word of mouth is how new users find it. Sustained advocates earn the badge below, displayed next to the contributors wall once awarded:
|
||||
|
||||
+53
-9
@@ -7,7 +7,7 @@
|
||||
·
|
||||
<strong>简体中文</strong>
|
||||
·
|
||||
<a href="https://esengine.github.io/reasonix/">官方网站</a>
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">架构文档</a>
|
||||
·
|
||||
@@ -52,19 +52,63 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
|
||||
要求 Node ≥ 22。已在 macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)测过。[去拿 DeepSeek API Key →](https://platform.deepseek.com/api_keys) · 完整 flag 看 `reasonix code --help`。
|
||||
|
||||
`npx` 是推荐路径 —— 不用全局安装,每次都拿到最新版本。如果你天天用、想把 `reasonix` 装到 `PATH` 上,跑一次 `reasonix update` 就行,它会替你跑 `npm install -g`。
|
||||
|
||||
### 子命令速查
|
||||
|
||||
| 命令 | 适用场景 |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | 锁在某个项目根目录的编码 agent。**先用这个。** |
|
||||
| `reasonix chat` | 纯聊天 —— 不挂文件系统工具,只是带历史的对话。 |
|
||||
| `reasonix run "task"` | 一次性,把答案直接流到 stdout。适合 shell 管道。 |
|
||||
| `reasonix doctor` | 环境体检(Node 版本、API Key、MCP 接线)。 |
|
||||
| `reasonix update` | 升级 Reasonix 本身。 |
|
||||
|
||||
其他子命令(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)见 `reasonix --help` 和 [CLI 参考](https://esengine.github.io/DeepSeek-Reasonix/#cli)。
|
||||
|
||||
#### 什么时候用 `chat` 而不是 `code`
|
||||
|
||||
`code` 是默认入口 —— 只有它能读写、编辑文件、跑 shell 命令、解析 SEARCH/REPLACE 块。`chat` 把这些全砍掉,只剩一个纯对话外壳;当你只想找模型对一对思路、又不想授予文件系统或 shell 权限时用它。
|
||||
|
||||
| 你拿到什么 | `reasonix code` | `reasonix chat` |
|
||||
|---|---|---|
|
||||
| 原生文件系统工具(read · write · `edit_file`) | ✓ | — |
|
||||
| SEARCH/REPLACE 编辑块 → `/apply` 审核 | ✓ | — |
|
||||
| Shell 工具(带 confirm gate,`/mode yolo` 跳过) | ✓ | — |
|
||||
| Plan 模式 · `submit_plan` · `/todo` · `/skill new` · `/mcp add` 脚手架 | ✓ | — |
|
||||
| 记忆工具(`remember` / `recall_memory`) | 项目 + 全局 | 仅全局 |
|
||||
| Web 搜索 · `ask_choice` · 从配置加载的 MCP 服务 | ✓ | ✓ |
|
||||
| 编码导向系统提示词(SEARCH/REPLACE、仓库礼仪) | ✓ | 通用 |
|
||||
| Session 作用域 | 按目录(`code-<basename>`) | 共享默认 |
|
||||
|
||||
`chat` 同样会从 `~/.reasonix/config.json` 读取 MCP 服务器,所以如果你只是想要一个聊天外壳挂一两个 MCP 集成,它是更轻量的入口。其他场景下,文档里所有截图、基准测试、slash 命令例子默认都是 `code`。
|
||||
|
||||
**在其他目录工作:** Reasonix 把文件系统工具作用域绑定在启动目录。要在别的目录工作,传 `--dir`:
|
||||
|
||||
```bash
|
||||
npx reasonix code --dir /path/to/project # 也可以用相对路径
|
||||
```
|
||||
|
||||
中途切换工作区是有意不支持的(消息日志和 memory 路径会和旧的根目录混在一起,状态错乱)。退出后用新的 `--dir` 重新启动来切换。`/status` 始终显示当前锁定的工作区。
|
||||
|
||||
**写第一个 Skill:** Skills 是模型可以调用的 markdown 剧本(`/skill <name>`)。暂无在线市场 —— 自己写:
|
||||
|
||||
```bash
|
||||
/skill new my-skill # 在 <project>/.reasonix/skills/my-skill.md 生成模板
|
||||
/skill new my-skill --global # 或者放到 ~/.reasonix/skills,跨项目共用
|
||||
```
|
||||
|
||||
编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 里加 `runAs: subagent` 会以独立 subagent 跑,而不是把正文内联进父 prompt。
|
||||
|
||||
<br/>
|
||||
|
||||
## Reasonix 的不同之处
|
||||
|
||||
整个循环围绕四根支柱组织。每一根解决的都是通用 agent 框架根本看不见的问题 —— 因为它们是为另一种缓存机制设计的。
|
||||
|
||||
<p align="center">
|
||||
<a href="./docs/ARCHITECTURE.md"><img src="docs/assets/pillars.zh-CN.svg" alt="Reasonix 四大支柱 — 缓存优先循环、R1 思维提取、工具调用修复、成本控制" width="880"/></a>
|
||||
</p>
|
||||
整个循环围绕三根支柱组织。每一根解决的都是通用 agent 框架根本看不见的问题 —— 因为它们是为另一种缓存机制设计的。
|
||||
|
||||
<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)
|
||||
各支柱完整说明 → [Pillar 1 — 缓存优先循环](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2 — 工具调用修复](./docs/ARCHITECTURE.md#pillar-2--tool-call-repair) · [Pillar 3 — 成本控制](./docs/ARCHITECTURE.md#pillar-3--cost-control-v06)
|
||||
|
||||
</sub>
|
||||
|
||||
@@ -99,7 +143,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
|
||||
- [**架构**](./docs/ARCHITECTURE.md) —— 四大支柱、缓存优先循环、思维提取、脚手架
|
||||
- [**基准测试**](./benchmarks/) —— τ-bench-lite harness、transcript、成本方法论
|
||||
- [**官方网站**](https://esengine.github.io/reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
|
||||
- [**官方网站**](https://esengine.github.io/DeepSeek-Reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
|
||||
- [**贡献指南**](./CONTRIBUTING.md) —— 注释规则、错误处理、用现成库不手写
|
||||
- [**行为准则**](./CODE_OF_CONDUCT.md) · [**安全策略**](./SECURITY.md)
|
||||
|
||||
@@ -115,7 +159,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
**正在征集意见的 Discussions:**
|
||||
|
||||
- [#20 · CLI / TUI 设计](https://github.com/esengine/reasonix/discussions/20) —— 哪里坏了、哪里少东西、哪里你会怎么改?
|
||||
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/reasonix/design/agent-dashboard.html)拍砖
|
||||
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)拍砖
|
||||
- [#22 · 未来功能愿望单](https://github.com/esengine/reasonix/discussions/22) —— 你希望 Reasonix 长出什么功能?
|
||||
|
||||
**正在使用 Reasonix,愿意让更多人了解它?** 欢迎将相关博客、文章、截图、演讲或视频发布到 [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell)。项目没有营销预算,新用户主要通过社区口碑找到这里。持续参与传播的用户将获得下方这枚徽章,颁发后会展示在贡献者墙旁:
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# Harvest eval harness (Pillar 2)
|
||||
|
||||
Measures Pillar 2 (R1 Thought Harvesting) the same way `tau-bench/`
|
||||
measures Pillar 1 (Cache-First Loop): isolate one variable, produce
|
||||
numbers anyone can reproduce.
|
||||
|
||||
## What's different from τ-bench-lite?
|
||||
|
||||
Deliberately different task shape:
|
||||
|
||||
| | τ-bench-lite | harvest-bench |
|
||||
|---|---|---|
|
||||
| task style | multi-turn tool-use | single-turn reasoning |
|
||||
| user simulator | yes (LLM) | no |
|
||||
| DB state | yes | no |
|
||||
| checker | DB-predicate | regex + set/value compare |
|
||||
| target | Pillar 1 | Pillar 2 |
|
||||
| model | deepseek-chat (default) | deepseek-reasoner (only R1 has `reasoning_content` for harvest to work on) |
|
||||
|
||||
The two harnesses answer different questions:
|
||||
|
||||
- **τ-bench-lite**: *"Does Cache-First actually cut cost on real tool-use
|
||||
workflows?"* (yes — 47.7pp cache, −39% cost on 48-run data.)
|
||||
- **harvest-bench**: *"Does the extra V3 harvest call add measurable
|
||||
value above plain R1?"* (TBD — run it to find out.)
|
||||
|
||||
## Modes
|
||||
|
||||
Three modes isolate one variable each:
|
||||
|
||||
| mode | model | harvest | what it measures |
|
||||
|---|---|---|---|
|
||||
| `baseline` | deepseek-chat | off | floor reference — V3 at its best |
|
||||
| `reasoner` | deepseek-reasoner | off | raw R1 gain over V3 on reasoning |
|
||||
| `reasoner-harvest` | deepseek-reasoner | on | R1 + the extra V3 harvest call |
|
||||
|
||||
Deltas:
|
||||
|
||||
- `baseline → reasoner` answers "is R1 worth the price on these
|
||||
problems?"
|
||||
- `reasoner → reasoner-harvest` answers "is the harvest call worth
|
||||
its incremental cost?"
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# Dry-run — no API, smoke-test the wiring
|
||||
npx tsx benchmarks/harvest/runner.ts --dry
|
||||
|
||||
# Full run (live DeepSeek, costs ~$0.20-0.60 for 6 tasks × 3 modes × 1 repeat)
|
||||
export DEEPSEEK_API_KEY=sk-...
|
||||
npx tsx benchmarks/harvest/runner.ts
|
||||
|
||||
# Tighter run with 3 repeats (~$0.60-2.00)
|
||||
npx tsx benchmarks/harvest/runner.ts --repeats 3
|
||||
|
||||
# Only the hard tasks (skip the easy floor)
|
||||
npx tsx benchmarks/harvest/runner.ts --task pseudoprime_base2
|
||||
npx tsx benchmarks/harvest/runner.ts --task derangements_d7
|
||||
npx tsx benchmarks/harvest/runner.ts --task euler_quadratic_break
|
||||
|
||||
# Bump per-call timeout (reasoner + harvest occasionally blow past the 120s default)
|
||||
npx tsx benchmarks/harvest/runner.ts --timeout 600
|
||||
|
||||
# Per-run transcripts so you can reasonix replay / diff them
|
||||
npx tsx benchmarks/harvest/runner.ts --repeats 3 --transcripts-dir transcripts/
|
||||
|
||||
# Render report
|
||||
npx tsx benchmarks/harvest/report.ts benchmarks/harvest/results-*.json
|
||||
```
|
||||
|
||||
## Tasks (v0.3 seed)
|
||||
|
||||
Split into two bands:
|
||||
|
||||
**Easy band** — V3 chat solves these, so baseline has a real pass rate and the bench has a reference floor.
|
||||
|
||||
| id | shape | why |
|
||||
|---|---|---|
|
||||
| `mod7_list` | number theory, 29-element list | R1 often tries enumeration first, then reaches for modular arithmetic — clean rejectedPaths signal |
|
||||
| `flips_until_3heads` | probability, single integer | classic recurrence; R1 either derives or recalls, harvest should see hypotheses diverge |
|
||||
| `three_hats` | logic puzzle, one-word answer | pure deduction chain, tests harvest's ability to extract the nested reasoning |
|
||||
|
||||
**Hard band** — picked specifically for known V3 failure modes. If R1 systematically beats V3 on these, and harvest further beats R1-only, we have our Pillar 2 story.
|
||||
|
||||
| id | shape | why V3 tends to fail |
|
||||
|---|---|---|
|
||||
| `pseudoprime_base2` | smallest Fermat pseudoprime to base 2 = **341** | V3 often answers 561 (Carmichael) or some other composite it remembers — R1 actually checks 2^n mod n |
|
||||
| `derangements_d7` | D_7 = **1854** | V3 approximates via n!/e and rounds wrong, or recalls D_6/D_8; R1 uses the recurrence D_n=(n−1)(D_{n−1}+D_{n−2}) |
|
||||
| `euler_quadratic_break` | smallest n where n²+n+41 is composite = **40** | V3 frequently confuses with n²−n+41 (first fails at 41); R1 checks small n systematically |
|
||||
|
||||
Adding a new task: see `tasks.ts`. Any checker that's deterministic is
|
||||
fair game — extract numeric/list/text with regex, compare with set
|
||||
equality or string matching.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No LLM-as-judge.** Brittle and expensive; defeats the point of a
|
||||
reproducible bench. If a checker is too hard to write deterministically,
|
||||
the task doesn't belong here.
|
||||
- **No tool-use tasks.** Those live in `tau-bench/`. Different story.
|
||||
- **No multi-turn.** v0.3 harness is single-turn Q/A. Multi-turn reasoning
|
||||
eval is separate scope.
|
||||
- **No benchmark-data cherry-picking.** When results come in, we publish
|
||||
them whether they validate Pillar 2 or not. "harvest didn't help on
|
||||
these 3 tasks" is still useful information.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Reasonix harvest eval (Pillar 2)
|
||||
|
||||
**Date:** 2026-04-21T16:44:21.011Z
|
||||
**Tasks:** 6 · repeats × 1 · modes: baseline, reasoner, reasoner-harvest
|
||||
**Reasonix version:** 0.2.2
|
||||
|
||||
## Summary by mode
|
||||
|
||||
| mode | runs | pass rate | cache hit | cost / run | harvest turns | subgoals | uncertainties |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| baseline | 6 | 100% | 39.5% | $0.001371 | 0.0 | 0.0 | 0.0 |
|
||||
| reasoner | 6 | 100% | 74.2% | $0.004170 | 0.0 | 0.0 | 0.0 |
|
||||
| reasoner-harvest | 6 | 83% | 61.6% | $0.003304 | 0.8 | 2.8 | 1.3 |
|
||||
|
||||
## Deltas
|
||||
|
||||
- **baseline → reasoner**
|
||||
- pass rate: 0pp
|
||||
- cost: ×3.04 (each run costs more)
|
||||
- harvest signal / run: 0.0 subgoals, 0.0 uncertainties
|
||||
|
||||
- **baseline → reasoner-harvest**
|
||||
- pass rate: -17pp
|
||||
- cost: ×2.41 (each run costs more)
|
||||
- harvest signal / run: 2.8 subgoals, 1.3 uncertainties
|
||||
|
||||
## Per-task breakdown
|
||||
|
||||
| task | mode | rep | verdict | cache | cost | sg | un | note |
|
||||
|---|---|---:|:---:|---:|---:|---:|---:|---|
|
||||
| mod7_list | baseline | 1 | ✅ | 86.5% | $0.001361 | 0 | 0 | |
|
||||
| mod7_list | reasoner | 1 | ✅ | 86.5% | $0.004797 | 0 | 0 | |
|
||||
| mod7_list | reasoner-harvest | 1 | ✅ | 86.5% | $0.003045 | 4 | 2 | |
|
||||
| flips_until_3heads | baseline | 1 | ✅ | 92.8% | $0.001198 | 0 | 0 | |
|
||||
| flips_until_3heads | reasoner | 1 | ✅ | 92.8% | $0.002830 | 0 | 0 | |
|
||||
| flips_until_3heads | reasoner-harvest | 1 | ✅ | 92.8% | $0.001691 | 4 | 0 | |
|
||||
| three_hats | baseline | 1 | ✅ | 57.7% | $0.001514 | 0 | 0 | |
|
||||
| three_hats | reasoner | 1 | ✅ | 57.7% | $0.009340 | 0 | 0 | |
|
||||
| three_hats | reasoner-harvest | 1 | ✅ | 57.7% | $0.009651 | 3 | 2 | |
|
||||
| pseudoprime_base2 | baseline | 1 | ✅ | 0.0% | $0.001894 | 0 | 0 | |
|
||||
| pseudoprime_base2 | reasoner | 1 | ✅ | 75.3% | $0.003128 | 0 | 0 | |
|
||||
| pseudoprime_base2 | reasoner-harvest | 1 | ❌ | 0.0% | $0.000000 | 0 | 0 | This operation was aborted |
|
||||
| derangements_d7 | baseline | 1 | ✅ | 0.0% | $0.001273 | 0 | 0 | |
|
||||
| derangements_d7 | reasoner | 1 | ✅ | 71.9% | $0.003492 | 0 | 0 | |
|
||||
| derangements_d7 | reasoner-harvest | 1 | ✅ | 71.9% | $0.002843 | 3 | 2 | |
|
||||
| euler_quadratic_break | baseline | 1 | ✅ | 0.0% | $0.000985 | 0 | 0 | |
|
||||
| euler_quadratic_break | reasoner | 1 | ✅ | 61.0% | $0.001431 | 0 | 0 | |
|
||||
| euler_quadratic_break | reasoner-harvest | 1 | ✅ | 61.0% | $0.002594 | 3 | 2 | |
|
||||
|
||||
## Scope
|
||||
|
||||
Unlike τ-bench-lite, these tasks are single-turn reasoning problems (no user simulator, no DB, no tool calls). Checkers are deterministic — regex + set / value compare, never an LLM judge. The point is to isolate whether the Pillar 2 harvest step adds measurable value above plain reasoner usage.
|
||||
|
||||
Interpretation: `baseline` (chat / V3) is a floor. `reasoner` shows the raw R1 gain. `reasoner-harvest` isolates the cost + quality delta from the extra V3 harvest call.
|
||||
## Findings (v0.3, 18-run set)
|
||||
|
||||
Two bands of tasks tried (easy: mod7 / flips / hats; hard: pseudoprime341 / D_7 / Euler quadratic). The hard band was chosen for *known V3 failure modes*. Result:
|
||||
|
||||
1. **V3 chat passed all six tasks, including the three "hard" ones.** DeepSeek V3 knew 341 is the smallest base-2 pseudoprime, computed D_7=1854, and identified n=40 as the first Euler-polynomial failure. The tasks are well-known enough to be in training; the trap-answer variants (561, 265, 41) that the checkers specifically reject didn't fire.
|
||||
2. **Reasoner cost 3.04× baseline for identical pass rates (6/6 both sides).** On this task set, R1 adds cost and latency without measurable quality. The cache-hit story *does* extend to reasoner (74.2% mean), so Pillar 1 generalizes to R1 — but that's not a Pillar 2 story.
|
||||
3. **Harvest produced real signal** (mean 2.8 subgoals, 1.3 uncertainties per run where it fired) but didn't improve outcomes. One run hit the 300s timeout (bumped from 120s), suggesting reasoner-harvest latency is still unpredictable on some problems.
|
||||
|
||||
### What this tells us about Pillar 2 positioning
|
||||
|
||||
We've now given harvest two shots at showing answer-quality value on pure reasoning tasks. Both times V3 ate its lunch. This isn't a framework bug — it's a positioning signal:
|
||||
|
||||
- **On well-known math / logic / counting problems, DeepSeek V3 is strong enough that paying for R1 is not justified by accuracy.** Pillar 2's "smart preset" cost multiplier (10×) quoted in the README is real but for most single-question Q/A it buys nothing.
|
||||
- **Harvest's plausible value surfaces are not "better math answers".** More likely: (a) transparency / auditability for developers, (b) planning support when tools are in the mix, (c) driving branch-sampling on cases where uncertainty correlates with wrongness. None of those are tested here.
|
||||
|
||||
### Recommendation
|
||||
|
||||
Don't ship v0.3 on the "harvest makes answers more correct" claim — the data doesn't support it. Two honest paths forward:
|
||||
|
||||
- **Reframe Pillar 2 as a developer-facing introspection feature** (debug visibility into R1's planning). Keep harvest opt-in; stop implying it improves accuracy on end-user tasks.
|
||||
- **Move harvest eval to tool-use contexts** where uncertainty matters for *which tool to call next*, not *is this math right*. That's a new task shape — different harness, not a bigger task set here.
|
||||
|
||||
In either case, the next Reasonix release is better served by MCP client (Pillar 1 leverage across the ecosystem) than by a bigger harvest-bench data push.
|
||||
|
||||
### Notes
|
||||
|
||||
- Cache hit on baseline runs surprised me (39.5% mean). That's DeepSeek's *cross-session* prompt cache: the same system prompt ran many times across modes and runs warmed the server-side cache. Real cross-session evidence that Cache-First's value isn't only per-session.
|
||||
- 5-subgoals cap still hit uniformly. `HarvestOptions.maxItems` default is 5; harvest is truncating. Moot unless we find a setting where that matters.
|
||||
@@ -1,229 +0,0 @@
|
||||
/** Render a harvest-bench results.json into a markdown report. CLI usage in README. */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
ALL_MODES,
|
||||
type HarvestBenchReport,
|
||||
type HarvestMode,
|
||||
type HarvestRunResult,
|
||||
} from "./types.js";
|
||||
|
||||
interface CliArgs {
|
||||
input: string;
|
||||
outPath: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = { input: "", outPath: "benchmarks/harvest/report.md" };
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--out") out.outPath = argv[++i] ?? out.outPath;
|
||||
else if (a && !a.startsWith("--")) positional.push(a);
|
||||
}
|
||||
out.input = positional[0] ?? "";
|
||||
if (!out.input) {
|
||||
throw new Error("usage: npx tsx benchmarks/harvest/report.ts <results.json> [--out report.md]");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Agg {
|
||||
runs: number;
|
||||
passes: number;
|
||||
avgCache: number;
|
||||
avgCost: number;
|
||||
avgClaudeCost: number;
|
||||
avgHarvestedTurns: number;
|
||||
avgSubgoals: number;
|
||||
avgUncertainties: number;
|
||||
}
|
||||
|
||||
function aggregate(results: HarvestRunResult[]): Agg {
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
runs: 0,
|
||||
passes: 0,
|
||||
avgCache: 0,
|
||||
avgCost: 0,
|
||||
avgClaudeCost: 0,
|
||||
avgHarvestedTurns: 0,
|
||||
avgSubgoals: 0,
|
||||
avgUncertainties: 0,
|
||||
};
|
||||
}
|
||||
const passes = results.filter((r) => r.verdict === "pass").length;
|
||||
const mean = (fn: (r: HarvestRunResult) => number) =>
|
||||
results.reduce((s, r) => s + fn(r), 0) / results.length;
|
||||
return {
|
||||
runs: results.length,
|
||||
passes,
|
||||
avgCache: mean((r) => r.cacheHitRatio),
|
||||
avgCost: mean((r) => r.costUsd),
|
||||
avgClaudeCost: mean((r) => r.claudeEquivalentUsd),
|
||||
avgHarvestedTurns: mean((r) => r.harvestedTurns),
|
||||
avgSubgoals: mean((r) => r.totalSubgoals),
|
||||
avgUncertainties: mean((r) => r.totalUncertainties),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderReport(report: HarvestBenchReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# Reasonix harvest eval (Pillar 2)");
|
||||
lines.push("");
|
||||
lines.push(`**Date:** ${report.meta.date}`);
|
||||
lines.push(
|
||||
`**Tasks:** ${report.meta.taskCount} · repeats × ${report.meta.repeatsPerTask} · modes: ${report.meta.modesRun.join(", ")}`,
|
||||
);
|
||||
lines.push(`**Reasonix version:** ${report.meta.reasonixVersion}`);
|
||||
lines.push("");
|
||||
|
||||
// Per-mode summary
|
||||
lines.push("## Summary by mode");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"| mode | runs | pass rate | cache hit | cost / run | harvest turns | subgoals | uncertainties |",
|
||||
);
|
||||
lines.push("|---|---:|---:|---:|---:|---:|---:|---:|");
|
||||
for (const mode of ALL_MODES) {
|
||||
const rs = report.results.filter((r) => r.mode === mode);
|
||||
const a = aggregate(rs);
|
||||
if (a.runs === 0) continue;
|
||||
lines.push(
|
||||
`| ${mode} | ${a.runs} | ${pct(a.passes, a.runs)} | ${pct1(a.avgCache)} | $${a.avgCost.toFixed(6)} | ${a.avgHarvestedTurns.toFixed(1)} | ${a.avgSubgoals.toFixed(1)} | ${a.avgUncertainties.toFixed(1)} |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// Pairwise deltas that actually answer the question
|
||||
lines.push("## Deltas");
|
||||
lines.push("");
|
||||
const modes = ALL_MODES.filter((m) => report.results.some((r) => r.mode === m));
|
||||
if (modes.length >= 2) {
|
||||
const baseline = aggregate(report.results.filter((r) => r.mode === modes[0]));
|
||||
for (let i = 1; i < modes.length; i++) {
|
||||
const mode = modes[i]!;
|
||||
const agg = aggregate(report.results.filter((r) => r.mode === mode));
|
||||
const passDelta =
|
||||
agg.passes / Math.max(agg.runs, 1) - baseline.passes / Math.max(baseline.runs, 1);
|
||||
const costRatio = baseline.avgCost > 0 ? agg.avgCost / baseline.avgCost : 0;
|
||||
lines.push(`- **${modes[0]} → ${mode}**`);
|
||||
lines.push(` - pass rate: ${(passDelta * 100).toFixed(0)}pp`);
|
||||
lines.push(
|
||||
` - cost: ×${costRatio.toFixed(2)} (each run costs ${costRatio > 1 ? "more" : "less"})`,
|
||||
);
|
||||
lines.push(
|
||||
` - harvest signal / run: ${agg.avgSubgoals.toFixed(1)} subgoals, ${agg.avgUncertainties.toFixed(1)} uncertainties`,
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// Per-task breakdown
|
||||
lines.push("## Per-task breakdown");
|
||||
lines.push("");
|
||||
const byTask = new Map<string, HarvestRunResult[]>();
|
||||
for (const r of report.results) {
|
||||
const list = byTask.get(r.taskId) ?? [];
|
||||
list.push(r);
|
||||
byTask.set(r.taskId, list);
|
||||
}
|
||||
lines.push("| task | mode | rep | verdict | cache | cost | sg | un | note |");
|
||||
lines.push("|---|---|---:|:---:|---:|---:|---:|---:|---|");
|
||||
for (const [taskId, runs] of byTask) {
|
||||
for (const r of runs) {
|
||||
const mark = r.verdict === "pass" ? "✅" : r.verdict === "fail" ? "❌" : "•";
|
||||
lines.push(
|
||||
`| ${taskId} | ${r.mode} | ${r.repeat} | ${mark} | ${pct1(r.cacheHitRatio)} | $${r.costUsd.toFixed(6)} | ${r.totalSubgoals} | ${r.totalUncertainties} | ${truncate(r.checkNote ?? "", 40)} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Scope");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Unlike τ-bench-lite, these tasks are single-turn reasoning problems (no user simulator, no DB, no tool calls). Checkers are deterministic — regex + set / value compare, never an LLM judge. The point is to isolate whether the Pillar 2 harvest step adds measurable value above plain reasoner usage.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Interpretation: `baseline` (chat / V3) is a floor. `reasoner` shows the raw R1 gain. `reasoner-harvest` isolates the cost + quality delta from the extra V3 harvest call.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Findings prose lives in report.md (hand-written after rendering); this stub keeps the function shape. */
|
||||
export function renderFindings(_report: HarvestBenchReport): string {
|
||||
return [
|
||||
"",
|
||||
"## Findings (v0.3, 18-run set)",
|
||||
"",
|
||||
"Two bands of tasks tried (easy: mod7 / flips / hats; hard: pseudoprime341 / D_7 / Euler quadratic). The hard band was chosen for *known V3 failure modes*. Result:",
|
||||
"",
|
||||
'1. **V3 chat passed all six tasks, including the three "hard" ones.** DeepSeek V3 knew 341 is the smallest base-2 pseudoprime, computed D_7=1854, and identified n=40 as the first Euler-polynomial failure. The tasks are well-known enough to be in training; the trap-answer variants (561, 265, 41) that the checkers specifically reject didn\'t fire.',
|
||||
"2. **Reasoner cost 3.04× baseline for identical pass rates (6/6 both sides).** On this task set, R1 adds cost and latency without measurable quality. The cache-hit story *does* extend to reasoner (74.2% mean), so Pillar 1 generalizes to R1 — but that's not a Pillar 2 story.",
|
||||
"3. **Harvest produced real signal** (mean 2.8 subgoals, 1.3 uncertainties per run where it fired) but didn't improve outcomes. One run hit the 300s timeout (bumped from 120s), suggesting reasoner-harvest latency is still unpredictable on some problems.",
|
||||
"",
|
||||
"### What this tells us about Pillar 2 positioning",
|
||||
"",
|
||||
"We've now given harvest two shots at showing answer-quality value on pure reasoning tasks. Both times V3 ate its lunch. This isn't a framework bug — it's a positioning signal:",
|
||||
"",
|
||||
'- **On well-known math / logic / counting problems, DeepSeek V3 is strong enough that paying for R1 is not justified by accuracy.** Pillar 2\'s "smart preset" cost multiplier (10×) quoted in the README is real but for most single-question Q/A it buys nothing.',
|
||||
'- **Harvest\'s plausible value surfaces are not "better math answers".** More likely: (a) transparency / auditability for developers, (b) planning support when tools are in the mix, (c) driving branch-sampling on cases where uncertainty correlates with wrongness. None of those are tested here.',
|
||||
"",
|
||||
"### Recommendation",
|
||||
"",
|
||||
"Don't ship v0.3 on the \"harvest makes answers more correct\" claim — the data doesn't support it. Two honest paths forward:",
|
||||
"",
|
||||
"- **Reframe Pillar 2 as a developer-facing introspection feature** (debug visibility into R1's planning). Keep harvest opt-in; stop implying it improves accuracy on end-user tasks.",
|
||||
"- **Move harvest eval to tool-use contexts** where uncertainty matters for *which tool to call next*, not *is this math right*. That's a new task shape — different harness, not a bigger task set here.",
|
||||
"",
|
||||
"In either case, the next Reasonix release is better served by MCP client (Pillar 1 leverage across the ecosystem) than by a bigger harvest-bench data push.",
|
||||
"",
|
||||
"### Notes",
|
||||
"",
|
||||
"- Cache hit on baseline runs surprised me (39.5% mean). That's DeepSeek's *cross-session* prompt cache: the same system prompt ran many times across modes and runs warmed the server-side cache. Real cross-session evidence that Cache-First's value isn't only per-session.",
|
||||
"- 5-subgoals cap still hit uniformly. `HarvestOptions.maxItems` default is 5; harvest is truncating. Moot unless we find a setting where that matters.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function pct(num: number, denom: number): string {
|
||||
if (denom === 0) return "—";
|
||||
return `${((num / denom) * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function pct1(x: number): string {
|
||||
return `${(x * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const raw = readFileSync(args.input, "utf8");
|
||||
const report = JSON.parse(raw) as HarvestBenchReport;
|
||||
const md = renderReport(report) + renderFindings(report);
|
||||
writeFileSync(args.outPath, md, "utf8");
|
||||
console.log(`wrote ${args.outPath} (${report.results.length} runs)`);
|
||||
}
|
||||
|
||||
function isMain(): boolean {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
return import.meta.url === pathToFileURL(entry).href;
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Suppress unused import warning — kept in scope so ALL_MODES order matches renderer iteration.
|
||||
export type { HarvestMode };
|
||||
@@ -1,305 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"date": "2026-04-21T16:44:21.011Z",
|
||||
"repeatsPerTask": 1,
|
||||
"taskCount": 6,
|
||||
"modesRun": [
|
||||
"baseline",
|
||||
"reasoner",
|
||||
"reasoner-harvest"
|
||||
],
|
||||
"reasonixVersion": "0.2.2"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"taskId": "mod7_list",
|
||||
"mode": "baseline",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "Alright, let's go step-by-step.\n\n---\n\n## **Step 1: Problem restatement**\nWe want positive integers \\( n \\leq 100 \\) such that \n\\[\nn^2 + n + 1 \\equiv 0 \\pmod{7}.\n\\]\n\n---\n\n## **Step 2: Try residues modulo 7**\nLet \\( n \\equiv r \\pmod{7} \\), where \\( r \\in \\{0, 1, 2, 3, 4, 5, 6\\} \\).\n\nWe compute \\( r^2 + r + 1 \\mod 7 \\):\n\n- \\( r = 0 \\): \\( 0 + 0 + 1 = 1 \\) → not divisible by 7.\n- \\( r = 1 \\): \\( 1 + 1 + 1 = 3 \\) → no.\n- \\( r = 2 \\): \\( 4 + 2 + 1 = 7 \\equiv 0 \\) → yes.\n- \\( r = 3 \\): \\( 9 + 3 + 1 = 13 \\equiv 6 \\) → no.\n- \\( r = 4 \\): \\( 16 + 4 + 1 = 21 \\equiv 0 \\) → yes.\n- \\( r = 5 \\): \\( 25 + 5 + 1 = 31 \\equiv 3 \\) → no.\n- \\( r = 6 \\): \\( 36 + 6 + 1 = 43 \\equiv 1 \\) → no.\n\nSo \\( n \\equiv 2 \\pmod{7} \\) or \\( n \\equiv 4 \\pmod{7} \\).\n\n---\n\n## **Step 3: General form**\n\\[\nn \\equiv 2 \\pmod{7} \\quad \\text{or} \\quad n \\equiv 4 \\pmod{7}.\n\\]\n\nThat means \\( n = 7k + 2 \\) or \\( n = 7k + 4 \\) for some integer \\( k \\ge 0 \\).\n\n---\n\n## **Step 4: Positive integers \\( n \\leq 100 \\)**\n**Case 1:** \\( n = 7k + 2 \\), \\( n \\leq 100 \\), \\( k \\ge 0 \\).\n\n\\( 7k + 2 \\leq 100 \\) → \\( 7k \\leq 98 \\) → \\( k \\leq 14 \\).\n\nSo \\( k = 0, 1, 2, \\dots, 14 \\) gives \\( n = 2, 9, 16, \\dots, 100 \\).\n\nCheck \\( n=100 \\): \\( 7 \\times 14 + 2 = 98 + 2 = 100 \\) okay.\n\n---\n\n**Case 2:** \\( n = 7k + 4 \\), \\( n \\leq 100 \\), \\( k \\ge 0 \\).\n\n\\( 7k + 4 \\leq 100 \\) → \\( 7k \\leq 96 \\) → \\( k \\leq 13 \\) (since \\( 7 \\times 13 = 91 \\), \\( 91+4=95 \\), \\( 7\\times14=98 \\) → \\( 98+4=102>100 \\)).\n\nSo \\( k = 0, 1, 2, \\dots, 13 \\) gives \\( n = 4, 11, 18, \\dots, 95 \\).\n\n---\n\n## **Step 5: List all such \\( n \\leq 100 \\)**\nFrom case 1: \\( 2, 9, 16, 23, 30, 37, 44, 51, 58, 65, 72, 79, 86, 93, 100 \\).\n\nFrom case 2: \\( 4, 11, 18, 25, 32, 39, 46, 53, 60, 67, 74, 81, 88, 95 \\).\n\nCombine and sort:\n\n\\[\n2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100.\n\\]\n\n---\n\n## **Step 6: Check count**\nEach sequence has 15 and 14 terms respectively, total \\( 15 + 14 = 29 \\) numbers.\n\nCheck: \\( n=2 \\) works? \\( 4+2+1=7 \\) yes. \\( n=4 \\) works? \\( 16+4+1=21 \\) yes. \\( n=100 \\) works? \\( 10000+100+1=10101 \\), divide by 7: \\( 10101/7 = 1443 \\) yes.\n\n---\n\n**Final answer:**\n\\[\n\\boxed{2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100}\n\\]\n\n---\n\nAnswer: 2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.8648648648648649,
|
||||
"costUsd": 0.0013612800000000003,
|
||||
"claudeEquivalentUsd": 0.018687,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "mod7_list",
|
||||
"mode": "reasoner",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The condition \\(n^2 + n + 1\\) divisible by 7 is equivalent to \\(n^2 + n + 1 \\equiv 0 \\pmod{7}\\). Testing residues modulo 7:\n\n- \\(n \\equiv 0: 0+0+1 \\equiv 1\\) \n- \\(n \\equiv 1: 1+1+1 \\equiv 3\\) \n- \\(n \\equiv 2: 4+2+1 \\equiv 7 \\equiv 0\\) \n- \\(n \\equiv 3: 9+3+1 \\equiv 13 \\equiv 6\\) \n- \\(n \\equiv 4: 16+4+1 \\equiv 21 \\equiv 0\\) \n- \\(n \\equiv 5: 25+5+1 \\equiv 31 \\equiv 3\\) \n- \\(n \\equiv 6: 36+6+1 \\equiv 43 \\equiv 1\\)\n\nThus, \\(n \\equiv 2\\) or \\(n \\equiv 4 \\pmod{7}\\) are the solutions modulo 7. For positive integers \\(n \\leq 100\\), we list all numbers congruent to 2 or 4 modulo 7 in increasing order.\n\nAnswer: 2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.8648648648648649,
|
||||
"costUsd": 0.00479742,
|
||||
"claudeEquivalentUsd": 0.032982,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "mod7_list",
|
||||
"mode": "reasoner-harvest",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The congruence \\(n^2 + n + 1 \\equiv 0 \\pmod{7}\\) is solved by testing residues modulo 7. The solutions are \\(n \\equiv 2\\) and \\(n \\equiv 4 \\pmod{7}\\). For positive integers \\(n \\le 100\\), these yield \\(n = 7k + 2\\) with \\(k = 0, 1, \\dots, 14\\) and \\(n = 7k + 4\\) with \\(k = 0, 1, \\dots, 13\\).\n\nAnswer: 2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.8648648648648649,
|
||||
"costUsd": 0.00304542,
|
||||
"claudeEquivalentUsd": 0.020982,
|
||||
"harvestedTurns": 1,
|
||||
"totalSubgoals": 4,
|
||||
"totalUncertainties": 2,
|
||||
"totalHypotheses": 2,
|
||||
"totalRejectedPaths": 2
|
||||
},
|
||||
{
|
||||
"taskId": "flips_until_3heads",
|
||||
"mode": "baseline",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "Alright, let's reason through this step-by-step.\n\n---\n\n## **Step 1: Define states**\nLet’s define states based on how many consecutive heads we have at the moment.\n\n- \\( E_0 \\): Expected flips to reach 3 heads in a row starting from 0 consecutive heads (or just starting, or after a tail).\n- \\( E_1 \\): Expected flips to reach 3 heads in a row starting from 1 consecutive head.\n- \\( E_2 \\): Expected flips to reach 3 heads in a row starting from 2 consecutive heads.\n\nWe want \\( E_0 \\).\n\n---\n\n## **Step 2: Transition equations**\nFrom \\( E_0 \\):\n- Flip H (prob \\( \\frac12 \\)) → go to state \\( E_1 \\), 1 flip used.\n- Flip T (prob \\( \\frac12 \\)) → stay in \\( E_0 \\), 1 flip used.\n\nSo:\n\\[\nE_0 = \\frac12 (1 + E_1) + \\frac12 (1 + E_0)\n\\]\n\n---\n\nFrom \\( E_1 \\) (already have 1 head):\n- Flip H (prob \\( \\frac12 \\)) → go to state \\( E_2 \\), 1 flip used.\n- Flip T (prob \\( \\frac12 \\)) → go to state \\( E_0 \\), 1 flip used.\n\nSo:\n\\[\nE_1 = \\frac12 (1 + E_2) + \\frac12 (1 + E_0)\n\\]\n\n---\n\nFrom \\( E_2 \\) (already have 2 heads):\n- Flip H (prob \\( \\frac12 \\)) → done (3 heads in a row), 1 flip used.\n- Flip T (prob \\( \\frac12 \\)) → go to state \\( E_0 \\), 1 flip used.\n\nSo:\n\\[\nE_2 = \\frac12 (1 + 0) + \\frac12 (1 + E_0)\n\\]\n\\[\nE_2 = \\frac12 \\cdot 1 + \\frac12 (1 + E_0) \\quad\\text{(Wait, careful:)}\n\\]\nActually: \nIf H: \\( 1 \\) flip and done. \nIf T: \\( 1 \\) flip and then \\( E_0 \\) more.\n\nSo:\n\\[\nE_2 = \\frac12 \\cdot 1 + \\frac12 (1 + E_0)\n\\]\nCheck: \nH: 1 flip → total 1, done. Contribution: \\( \\frac12 \\times 1 \\). \nT: 1 flip + \\( E_0 \\) more → total \\( 1 + E_0 \\). Contribution: \\( \\frac12 \\times (1 + E_0) \\).\n\nYes:\n\\[\nE_2 = \\frac12 \\cdot 1 + \\frac12 (1 + E_0) = \\frac12 + \\frac12 + \\frac12 E_0 = 1 + \\frac{E_0}{2}.\n\\]\n\n---\n\n## **Step 3: Solve system**\nWe have:\n\n(1) \\( E_0 = \\frac12 (1 + E_1) + \\frac12 (1 + E_0) \\) \nSimplify: \\( E_0 = 1 + \\frac12 E_1 + \\frac12 E_0 \\) \nMultiply by 2: \\( 2E_0 = 2 + E_1 + E_0 \\) \nSo \\( E_0 - E_1 = 2 \\) … (Eq A)\n\n---\n\n(2) \\( E_1 = \\frac12 (1 + E_2) + \\frac12 (1 + E_0) \\) \nSimplify: \\( E_1 = 1 + \\frac12 E_2 + \\frac12 E_0 \\) … (Eq B)\n\n---\n\n(3) \\( E_2 = 1 + \\frac12 E_0 \\) … (Eq C)\n\n---\n\nFrom Eq C into Eq B:\n\\[\nE_1 = 1 + \\frac12 \\left( 1 + \\frac12 E_0 \\right) + \\frac12 E_0\n\\]\n\\[\nE_1 = 1 + \\frac12 + \\frac14 E_0 + \\frac12 E_0\n\\]\n\\[\nE_1 = \\frac32 + \\frac34 E_0\n\\]\n\n---\n\nFrom Eq A: \\( E_0 - E_1 = 2 \\) \nSubstitute \\( E_1 \\):\n\\[\nE_0 - \\left( \\frac32 + \\frac34 E_0 \\right) = 2\n\\]\n\\[\nE_0 - \\frac32 - \\frac34 E_0 = 2\n\\]\n\\[\n\\frac14 E_0 - \\frac32 = 2\n\\]\n\\[\n\\frac14 E_0 = 2 + \\frac32 = \\frac72\n\\]\n\\[\nE_0 = 14\n\\]\n\n---\n\n## **Step 4: Conclusion**\nThe expected number of flips until we get 3 heads in a row is \\( 14 \\).\n\n---\n\n**Answer:** 14",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.927536231884058,
|
||||
"costUsd": 0.00119823,
|
||||
"claudeEquivalentUsd": 0.016467,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "flips_until_3heads",
|
||||
"mode": "reasoner",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The expected number of flips required to get three consecutive heads is 14, derived from solving the system of linear equations for the Markov chain states representing the current streak of heads.\n\nAnswer: 14",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.927536231884058,
|
||||
"costUsd": 0.00283024,
|
||||
"claudeEquivalentUsd": 0.019512,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "flips_until_3heads",
|
||||
"mode": "reasoner-harvest",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The expected number of flips until three consecutive heads appear is 14.\n\nAnswer: 14",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.927536231884058,
|
||||
"costUsd": 0.00169144,
|
||||
"claudeEquivalentUsd": 0.011712,
|
||||
"harvestedTurns": 1,
|
||||
"totalSubgoals": 4,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 2,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "three_hats",
|
||||
"mode": "baseline",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "Let's break this down step by step.\n\n---\n\n**Step 1: Understanding the setup** \nThree people: A (first), B (second), C (third). \nEach hat is red (R) or blue (B). \nThey know: at least one hat is red. \nEach sees the other two hats, not their own. \nThey are asked in order: \"Do you know your hat color?\" \nA answers no, B answers no, C answers yes. \nWe want C’s hat color.\n\n---\n\n**Step 2: From A’s perspective when asked** \nA sees B and C. \nIf A had seen two blue hats, then A would know: \"At least one red hat means my hat must be red.\" \nSo A says \"no\" → A did **not** see two blue hats. \nThus A sees at least one red hat on {B, C}.\n\nPossible cases for what A sees (B, C): \n(R, R), (R, B), (B, R) — all have at least one red.\n\n---\n\n**Step 3: B’s reasoning when asked** \nB hears A say \"no\". \nB sees A and C. \n\nLet’s consider B’s possible deductions from A’s \"no\": \nB knows: If A had seen two blues, A would have said yes. Since A said no, A saw at least one red among B and C. \nBut B sees A and C. \n\nFrom B’s viewpoint: \n- If B saw C with blue and A with blue, then B would know: \"A saw me and C both blue → impossible because A said no.\" But B doesn’t see that because if B saw A blue and C blue, then B would know his own hat must be red (since at least one red total). But then B would answer yes. \nSo B’s \"no\" means B did **not** see A and C both blue. \n\nAlso, B considers: Could my hat be blue? Let’s check: \nSuppose B sees A red, C blue. Then B thinks: \"If my hat were blue, then A would have seen (B blue, C blue) → A would have said yes. But A said no, so my hat cannot be blue.\" \nSo if B saw (A red, C blue), B would deduce: \"My hat must be red.\" Then B would answer yes. \nBut B said no → so B did **not** see (A red, C blue).\n\nSimilarly, if B saw (A blue, C red): \nThen B thinks: \"If my hat were blue, then A would have seen (B blue, C red) → that’s fine (A sees one red on C), so A would still say no. If my hat were red, then A would see (B red, C red) → A would still say no. So I don’t know.\" That’s consistent with B saying no. \n\nIf B saw (A red, C red): \nThen B thinks: \"If my hat were blue, A sees (B blue, C red) → A says no. If my hat were red, A sees (B red, C red) → A says no. So I don’t know.\" That’s also consistent with B saying no.\n\nSo B says no in cases: \nB sees (A blue, C red) or (A red, C red). \nB would say yes if B saw (A red, C blue) — but that didn’t happen. \n\n---\n\n**Step 4: C’s reasoning** \nC hears A says no, B says no. \nC sees A and B. \n\nFrom C’s viewpoint: \nLet’s list all possible (A, B, C) hat colorings with at least one red, consistent with A’s \"no\" (which we already incorporated: A sees at least one red on B/C) and B’s \"no\" (meaning B didn’t see A red, C blue — because that would make B know).\n\nPossible cases for (A, B) that C could see: \n\n1. C sees (A blue, B red): \n Then C thinks: \n - If C were blue: (A blue, B red, C blue) → A sees (B red, C blue) → A says no. B sees (A blue, C blue) → B sees two blues? Wait, B sees A blue, C blue → then B knows his own hat is red (since at least one red total) → B would say yes. But B said no → contradiction. So C cannot be blue. \n So C must be red. \n\n2. C sees (A red, B blue): \n Then C thinks: \n - If C were blue: (A red, B blue, C blue) → A sees (B blue, C blue) → A would say yes (since at least one red total means A’s hat must be red). But A said no → contradiction. So C cannot be blue. \n So C must be red. \n\n3. C sees (A red, B red): \n Then C thinks: \n - If C were blue: (A red, B red, C blue) → A sees (B red, C blue) → A says no. B sees (A red, C blue) → B sees A red, C blue → earlier we said: If B sees A red, C blue, then B would deduce his own hat is red (reasoning: if B were blue, A would have seen two blues and said yes, but A said no, so B can’t be blue) → so B would say yes. But B actually said no → contradiction. So C cannot be blue. \n - If C were red: (A red, B red, C red) → A sees (B red, C red) → A says no. B sees (A red, C red) → B says no. Works fine. \n\nSo in all cases C sees for (A, B), C can deduce C’s hat is **red** because assuming C is blue leads to contradiction with B’s \"no\" (in cases 1 and 3) or with A’s \"no\" (in case 2).\n\n---\n\n**Step 5: Conclusion** \nC answers yes, and C’s hat must be red.\n\n---\n\nAnswer: red",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.5765765765765766,
|
||||
"costUsd": 0.0015142700000000003,
|
||||
"claudeEquivalentUsd": 0.020748,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "three_hats",
|
||||
"mode": "reasoner",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "Answer: red",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.5765765765765766,
|
||||
"costUsd": 0.009340119999999999,
|
||||
"claudeEquivalentUsd": 0.064068,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "three_hats",
|
||||
"mode": "reasoner-harvest",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The third person deduces their hat color based on the previous responses. Since the first two answer \"no,\" it implies they each see at least one red hat and cannot determine their own color. Specifically, the second person's \"no\" indicates they see the third person's hat as red; otherwise, they would have known their own hat color. Therefore, the third person's hat must be red.\n\nAnswer: red",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.5765765765765766,
|
||||
"costUsd": 0.0096511,
|
||||
"claudeEquivalentUsd": 0.066198,
|
||||
"harvestedTurns": 1,
|
||||
"totalSubgoals": 3,
|
||||
"totalUncertainties": 2,
|
||||
"totalHypotheses": 3,
|
||||
"totalRejectedPaths": 3
|
||||
},
|
||||
{
|
||||
"taskId": "pseudoprime_base2",
|
||||
"mode": "baseline",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "Alright, let's go step-by-step.\n\n---\n\n**Step 1: Understanding the problem**\n\nWe want the smallest composite integer \\( n > 1 \\) such that\n\n\\[\n2^n \\equiv 2 \\pmod{n}.\n\\]\n\nEquivalently, \\( 2^{n-1} \\equiv 1 \\pmod{n} \\) for composite \\( n \\).\n\nThis means \\( n \\) is a **Fermat pseudoprime to base 2**.\n\nWe check composite numbers starting from the smallest.\n\n---\n\n**Step 2: Check small composites**\n\n- \\( n = 4 \\): composite. \n\\( 2^4 = 16 \\), \\( 16 \\mod 4 = 0 \\), not \\( 2 \\). So \\( 2^4 \\equiv 0 \\not\\equiv 2 \\pmod{4} \\). Fails.\n\n- \\( n = 6 \\): composite. \n\\( 2^6 = 64 \\), \\( 64 \\mod 6 = 4 \\), not \\( 2 \\). Fails.\n\n- \\( n = 8 \\): composite. \n\\( 2^8 = 256 \\), \\( 256 \\mod 8 = 0 \\), not \\( 2 \\). Fails.\n\n- \\( n = 9 \\): composite. \n\\( 2^9 = 512 \\), \\( 512 \\mod 9 \\): \n\\( 512 \\div 9 \\), \\( 9 \\times 56 = 504 \\), remainder \\( 8 \\), not \\( 2 \\). Fails.\n\n- \\( n = 10 \\): composite. \n\\( 2^{10} = 1024 \\), \\( 1024 \\mod 10 = 4 \\), not \\( 2 \\). Fails.\n\n---\n\n**Step 3: Try n = 15**\n\n\\( n = 15 \\): composite. \n\\( 2^{15} = 32768 \\). \nCheck mod 15: \nWe can use mod arithmetic: \n\\( 2^4 = 16 \\equiv 1 \\pmod{15} \\)?? Wait, check: \\( 16 \\mod 15 = 1 \\), yes. \nSo \\( 2^{15} = (2^4)^3 \\times 2^3 = 1^3 \\times 8 = 8 \\pmod{15} \\). \nSo \\( 2^{15} \\equiv 8 \\not\\equiv 2 \\pmod{15} \\). Fails.\n\n---\n\n**Step 4: Try n = 21**\n\n\\( n = 21 \\): composite. \nMod 21: \n\\( 2^6 = 64 \\equiv 1 \\pmod{21} \\)? Check: \\( 64 \\div 21 = 3 \\times 21 = 63 \\), remainder 1, yes. \nSo \\( 2^{21} = (2^6)^3 \\times 2^3 \\equiv 1^3 \\times 8 = 8 \\pmod{21} \\). Not 2. Fails.\n\n---\n\n**Step 5: Try n = 22**\n\n\\( n = 22 \\): composite. \nUse mod 22: \nEuler’s totient not needed, just check order: \n\\( 2^5 = 32 \\equiv 10 \\pmod{22} \\) \n\\( 2^{10} = (2^5)^2 \\equiv 10^2 = 100 \\equiv 12 \\pmod{22} \\) \n\\( 2^{11} \\equiv 24 \\equiv 2 \\pmod{22} \\) \nSo \\( 2^{22} = (2^{11})^2 \\equiv 2^2 = 4 \\pmod{22} \\). Not 2. Fails.\n\n---\n\n**Step 6: Try n = 25**\n\n\\( n = 25 \\): composite. \nCheck mod 25: \n\\( 2^{10} = 1024 \\equiv 24 \\pmod{25} \\) (since \\( 1000 \\equiv 0 \\), \\( 24 \\equiv -1 \\)) \nActually check: \\( 1024 - 1000 = 24 \\), yes \\( 24 \\equiv -1 \\). \nSo \\( 2^{20} \\equiv 1 \\pmod{25} \\). \nThus \\( 2^{25} = 2^{20} \\times 2^5 \\equiv 1 \\times 32 = 7 \\pmod{25} \\). Not 2. Fails.\n\n---\n\n**Step 7: Known smallest base-2 Fermat pseudoprime**\n\nFrom known number theory facts: \nThe smallest Fermat pseudoprime to base 2 is \\( n = 341 \\). \nBut let's check smaller ones just in case I’m skipping one.\n\n---\n\n**Step 8: Try n = 91**\n\n91 = 7 × 13. \nCheck mod 7: \\( 2^{91} \\equiv (2^6)^{15} \\times 2^1 \\equiv 1^{15} \\times 2 = 2 \\pmod{7} \\). \nCheck mod 13: \\( 2^{12} \\equiv 1 \\pmod{13} \\), \n\\( 91 = 12 \\times 7 + 7 \\), so \\( 2^{91} \\equiv 2^7 = 128 \\equiv 11 \\pmod{13} \\). \nFor \\( 2^{91} \\equiv 2 \\pmod{91} \\), we need \\( 2^{91} \\equiv 2 \\) mod 7 and mod 13. \nMod 13 we got 11, not 2. So fails.\n\n---\n\n**Step 9: Try n = 341**\n\n341 = 11 × 31. \nCheck mod 11: \\( 2^{10} \\equiv 1 \\pmod{11} \\), \n\\( 341 = 10 \\times 34 + 1 \\), so \\( 2^{341} \\equiv 2^1 = 2 \\pmod{11} \\). Good. \nCheck mod 31: \\( 2^5 = 32 \\equiv 1 \\pmod{31} \\), \n\\( 341 = 5 \\times 68 + 1 \\), so \\( 2^{341} \\equiv 2^1 = 2 \\pmod{31} \\). \nSo \\( 2^{341} \\equiv 2 \\) mod 11 and mod 31, hence mod 341. \nThus \\( 341 \\) works.\n\n---\n\n**Step 10: Check smaller than 341**\n\nKnown list: 341 is smallest. Let’s check a candidate like 121 = 11^2: \n\\( 2^{121} \\mod 121 \\): \nMod 11: \\( 2^{10} \\equiv 1 \\), \\( 121 = 10 \\times 12 + 1 \\), so \\( 2^{121} \\equiv 2 \\pmod{11} \\). \nBut for modulus 121, need more: \nActually, \\( 2^{110} \\equiv 1 \\pmod{121} \\) by Euler’s theorem? Not exactly, but known: 121 is not a pseudoprime base 2. Quick check: \n\\( 2^{121} - 2 \\) divisible by 121? Probably not. Let’s skip detailed check; known fact is 341 is smallest.\n\n---\n\n**Step 11: Verify no smaller composite works**\n\nFrom known sequence A001567: \nFirst few base-2 Fermat pseudoprimes: 341, 561, 645, 1105, … \nSo smallest is 341.\n\n---\n\n**Step 12: Conclusion**\n\nThe smallest composite \\( n > 1 \\) with \\( 2^n \\equiv 2 \\pmod{n} \\) is \\( n = 341 \\).\n\n---\n\n**Answer:** 341",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0,
|
||||
"costUsd": 0.0018940500000000002,
|
||||
"claudeEquivalentUsd": 0.02577,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "pseudoprime_base2",
|
||||
"mode": "reasoner",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The smallest composite integer \\(n > 1\\) satisfying \\(2^n \\equiv 2 \\pmod{n}\\) is \\(n = 341\\). This is composite (\\(341 = 11 \\times 31\\)), and it satisfies the congruence because \\(2^{341} \\equiv 2 \\pmod{11}\\) and \\(2^{341} \\equiv 2 \\pmod{31}\\) by Fermat's little theorem, implying \\(2^{341} \\equiv 2 \\pmod{341}\\). All smaller composite integers (e.g., 4, 6, 8, 9, 10, 15, 21) fail to satisfy the condition.\n\nAnswer: 341",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.7529411764705882,
|
||||
"costUsd": 0.0031281200000000003,
|
||||
"claudeEquivalentUsd": 0.02154,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "pseudoprime_base2",
|
||||
"mode": "reasoner-harvest",
|
||||
"repeat": 1,
|
||||
"verdict": "fail",
|
||||
"checkNote": "This operation was aborted",
|
||||
"finalAgentMessage": "",
|
||||
"turns": 0,
|
||||
"cacheHitRatio": 0,
|
||||
"costUsd": 0,
|
||||
"claudeEquivalentUsd": 0,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0,
|
||||
"errorMessage": "This operation was aborted"
|
||||
},
|
||||
{
|
||||
"taskId": "derangements_d7",
|
||||
"mode": "baseline",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "Alright, let's go step-by-step.\n\n---\n\n**Step 1: Understanding the problem**\n\nWe want the number of permutations of \\(\\{1, 2, 3, 4, 5, 6, 7\\}\\) with **no fixed points**. \nA fixed point means \\(\\pi(i) = i\\) for some \\(i\\). \nA permutation with no fixed points is called a **derangement**. \nThe number of derangements of \\(n\\) elements is denoted \\(D_n\\) or \\(!n\\).\n\n---\n\n**Step 2: Formula for derangements**\n\nThe formula for \\(D_n\\) is:\n\n\\[\nD_n = n! \\sum_{k=0}^{n} \\frac{(-1)^k}{k!}\n\\]\n\nFor \\(n=7\\):\n\n\\[\nD_7 = 7! \\left( \\frac{1}{0!} - \\frac{1}{1!} + \\frac{1}{2!} - \\frac{1}{3!} + \\frac{1}{4!} - \\frac{1}{5!} + \\frac{1}{6!} - \\frac{1}{7!} \\right)\n\\]\n\n---\n\n**Step 3: Compute step-by-step**\n\nFirst, \\(7! = 5040\\).\n\nNow compute the sum:\n\n\\[\n\\sum_{k=0}^{7} \\frac{(-1)^k}{k!} \n= 1 - 1 + \\frac{1}{2} - \\frac{1}{6} + \\frac{1}{24} - \\frac{1}{120} + \\frac{1}{720} - \\frac{1}{5040}\n\\]\n\nLet's compute term-by-term:\n\n- \\(1 - 1 = 0\\)\n- \\(0 + 0.5 = 0.5\\)\n- \\(0.5 - \\frac{1}{6} \\approx 0.5 - 0.1666667 = 0.3333333\\)\n- \\(0.3333333 + \\frac{1}{24} \\approx 0.3333333 + 0.0416667 = 0.375\\)\n- \\(0.375 - \\frac{1}{120} \\approx 0.375 - 0.0083333 = 0.3666667\\)\n- \\(0.3666667 + \\frac{1}{720} \\approx 0.3666667 + 0.0013889 = 0.3680556\\)\n- \\(0.3680556 - \\frac{1}{5040} \\approx 0.3680556 - 0.0001984 = 0.3678572\\)\n\nSo the sum is approximately \\(0.367857142857...\\)\n\n---\n\n**Step 4: Multiply by \\(7!\\)**\n\n\\[\nD_7 = 5040 \\times 0.367857142857\\ldots\n\\]\n\nLet's compute exactly using fractions:\n\n\\[\n\\sum_{k=0}^{7} \\frac{(-1)^k}{k!} \n= \\frac{1}{2!} - \\frac{1}{3!} + \\frac{1}{4!} - \\frac{1}{5!} + \\frac{1}{6!} - \\frac{1}{7!}\n\\]\n\nWrite with common denominator \\(7! = 5040\\):\n\n- \\(\\frac{1}{2!} = \\frac{2520}{5040}\\)\n- \\(\\frac{1}{3!} = \\frac{840}{5040}\\)\n- \\(\\frac{1}{4!} = \\frac{210}{5040}\\)\n- \\(\\frac{1}{5!} = \\frac{42}{5040}\\)\n- \\(\\frac{1}{6!} = \\frac{7}{5040}\\)\n- \\(\\frac{1}{7!} = \\frac{1}{5040}\\)\n\nNow alternate signs:\n\n\\[\n\\left( \\frac{2520}{5040} - \\frac{840}{5040} \\right) \n= \\frac{1680}{5040}\n\\]\n\\[\n\\frac{1680}{5040} + \\frac{210}{5040} = \\frac{1890}{5040}\n\\]\n\\[\n\\frac{1890}{5040} - \\frac{42}{5040} = \\frac{1848}{5040}\n\\]\n\\[\n\\frac{1848}{5040} + \\frac{7}{5040} = \\frac{1855}{5040}\n\\]\n\\[\n\\frac{1855}{5040} - \\frac{1}{5040} = \\frac{1854}{5040}\n\\]\n\nSo the sum is \\(\\frac{1854}{5040}\\).\n\n---\n\n**Step 5: Multiply by \\(7!\\)**\n\n\\[\nD_7 = 5040 \\times \\frac{1854}{5040} = 1854\n\\]\n\n---\n\n**Step 6: Quick check with known derangement numbers**\n\nKnown small derangements: \n\\(D_1 = 0, D_2 = 1, D_3 = 2, D_4 = 9, D_5 = 44, D_6 = 265, D_7 = 1854\\). \nYes, matches known sequence.\n\n---\n\n**Final answer:**\n\nAnswer: 1854",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0,
|
||||
"costUsd": 0.00127253,
|
||||
"claudeEquivalentUsd": 0.017292,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "derangements_d7",
|
||||
"mode": "reasoner",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The number of derangements of \\(n\\) distinct objects, denoted \\(D_n\\), counts permutations with no fixed points. For \\(n = 7\\), this can be computed using the recurrence \\(D_n = (n-1)(D_{n-1} + D_{n-2})\\) with initial values \\(D_1 = 0\\) and \\(D_2 = 1\\). \nThus: \n\\(D_3 = 2(1+0) = 2\\), \n\\(D_4 = 3(2+1) = 9\\), \n\\(D_5 = 4(9+2) = 44\\), \n\\(D_6 = 5(44+9) = 265\\), \n\\(D_7 = 6(265+44) = 1854\\).\n\nAnswer: 1854",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.7191011235955056,
|
||||
"costUsd": 0.00349167,
|
||||
"claudeEquivalentUsd": 0.024027,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "derangements_d7",
|
||||
"mode": "reasoner-harvest",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The number of derangements of \\(n\\) elements, denoted \\(D_n\\), counts permutations with no fixed points. For \\(n=7\\), this can be computed using the recurrence \\(D_n = (n-1)(D_{n-1} + D_{n-2})\\) with initial values \\(D_0 = 1\\) and \\(D_1 = 0\\). Applying the recurrence yields \\(D_2 = 1\\), \\(D_3 = 2\\), \\(D_4 = 9\\), \\(D_5 = 44\\), \\(D_6 = 265\\), and finally \\(D_7 = 6 \\times (265 + 44) = 1854\\).\n\nAnswer: 1854",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.7191011235955056,
|
||||
"costUsd": 0.0028434299999999997,
|
||||
"claudeEquivalentUsd": 0.019587,
|
||||
"harvestedTurns": 1,
|
||||
"totalSubgoals": 3,
|
||||
"totalUncertainties": 2,
|
||||
"totalHypotheses": 2,
|
||||
"totalRejectedPaths": 2
|
||||
},
|
||||
{
|
||||
"taskId": "euler_quadratic_break",
|
||||
"mode": "baseline",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "Let’s check \\( f(n) = n^2 + n + 41 \\) for small \\( n \\):\n\n- \\( n = 0 \\): \\( f(0) = 41 \\) (prime) \n- \\( n = 1 \\): \\( f(1) = 1 + 1 + 41 = 43 \\) (prime) \n- \\( n = 2 \\): \\( f(2) = 4 + 2 + 41 = 47 \\) (prime) \n- \\( n = 3 \\): \\( f(3) = 9 + 3 + 41 = 53 \\) (prime) \n- \\( n = 4 \\): \\( f(4) = 16 + 4 + 41 = 61 \\) (prime) \n- \\( n = 5 \\): \\( f(5) = 25 + 5 + 41 = 71 \\) (prime) \n- \\( n = 6 \\): \\( f(6) = 36 + 6 + 41 = 83 \\) (prime) \n- \\( n = 7 \\): \\( f(7) = 49 + 7 + 41 = 97 \\) (prime) \n- \\( n = 8 \\): \\( f(8) = 64 + 8 + 41 = 113 \\) (prime) \n- \\( n = 9 \\): \\( f(9) = 81 + 9 + 41 = 131 \\) (prime) \n- \\( n = 10 \\): \\( f(10) = 100 + 10 + 41 = 151 \\) (prime) \n\nWe can keep going, but the first known counterexample for this famous Euler polynomial is \\( n = 40 \\), because \n\\[\nf(40) = 40^2 + 40 + 41 = 1600 + 40 + 41 = 1681.\n\\] \nCheck if \\( 1681 \\) is prime: \n\\( 1681 = 41^2 \\) (since \\( 41 \\times 41 = 1681 \\)), so it’s composite.\n\nBut wait — let’s check if there’s a smaller \\( n \\) where \\( f(n) \\) is composite. \nWe can test \\( n = 41 \\): \n\\( f(41) = 41^2 + 41 + 41 = 1681 + 82 = 1763 \\), which is \\( 41 \\times 43 \\) (since \\( 1763/41 = 43 \\)), so composite. But \\( n = 41 \\) is larger than \\( n = 40 \\).\n\nCheck \\( n = 39 \\): \\( f(39) = 1521 + 39 + 41 = 1601 \\). Test divisibility: not divisible by primes up to \\( \\sqrt{1601} \\approx 40 \\), seems prime. \n\nCheck \\( n = 40 \\) is indeed the first composite? Let’s check \\( n = 1 \\) to \\( 39 \\) quickly for a smaller counterexample: \nWe can note \\( f(n) = n(n+1) + 41 \\). If \\( 41 \\mid f(n) \\), then \\( 41 \\mid n(n+1) \\). \nThat happens when \\( n \\equiv 0 \\pmod{41} \\) or \\( n \\equiv 40 \\pmod{41} \\). \nThe smallest positive such \\( n \\) is \\( n = 40 \\) (since \\( n=41 \\) is larger). \nSo for \\( n = 40 \\), \\( f(40) = 40 \\times 41 + 41 = 41 \\times 41 \\) is composite. \n\nCheck \\( n = 0 \\) to \\( 39 \\): no multiple of 41 except \\( n=0 \\) (which gives 41, prime). So \\( n=40 \\) is indeed the first nonnegative integer where \\( f(n) \\) is composite.\n\nThus smallest \\( n \\) is \\( 40 \\), \\( f(40) = 1681 = 41^2 \\).\n\n**Answer:** 40",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0,
|
||||
"costUsd": 0.00098535,
|
||||
"claudeEquivalentUsd": 0.013365,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "euler_quadratic_break",
|
||||
"mode": "reasoner",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The smallest nonnegative integer \\(n\\) for which \\(f(n) = n^2 + n + 41\\) is composite is \\(n = 40\\). At this value, \\(f(40) = 40^2 + 40 + 41 = 1681 = 41^2\\), which is not prime.\n\nAnswer: 40",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.6095238095238096,
|
||||
"costUsd": 0.0014309199999999998,
|
||||
"claudeEquivalentUsd": 0.0099,
|
||||
"harvestedTurns": 0,
|
||||
"totalSubgoals": 0,
|
||||
"totalUncertainties": 0,
|
||||
"totalHypotheses": 0,
|
||||
"totalRejectedPaths": 0
|
||||
},
|
||||
{
|
||||
"taskId": "euler_quadratic_break",
|
||||
"mode": "reasoner-harvest",
|
||||
"repeat": 1,
|
||||
"verdict": "pass",
|
||||
"finalAgentMessage": "The polynomial \\(f(n) = n^2 + n + 41\\) is known to produce primes for \\(n = 0, 1, 2, \\dots, 39\\). At \\(n = 40\\), we have \\(f(40) = 40^2 + 40 + 41 = 1681\\), and \\(1681 = 41^2\\), which is composite. Therefore, \\(n = 40\\) is the smallest nonnegative integer where \\(f(n)\\) fails to be prime.\n\nAnswer: 40",
|
||||
"turns": 1,
|
||||
"cacheHitRatio": 0.6095238095238096,
|
||||
"costUsd": 0.0025938099999999998,
|
||||
"claudeEquivalentUsd": 0.017865,
|
||||
"harvestedTurns": 1,
|
||||
"totalSubgoals": 3,
|
||||
"totalUncertainties": 2,
|
||||
"totalHypotheses": 3,
|
||||
"totalRejectedPaths": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
/** Harvest-bench runner — writes results.json. CLI flags + sample invocations in benchmarks/README.md. */
|
||||
|
||||
import { type WriteStream, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
CacheFirstLoop,
|
||||
DeepSeekClient,
|
||||
ImmutablePrefix,
|
||||
claudeEquivalentCost,
|
||||
loadDotenv,
|
||||
} from "../../src/index.js";
|
||||
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../src/transcript/log.js";
|
||||
import { TASKS } from "./tasks.js";
|
||||
import type {
|
||||
HarvestBenchMeta,
|
||||
HarvestBenchReport,
|
||||
HarvestMode,
|
||||
HarvestRunResult,
|
||||
HarvestTask,
|
||||
} from "./types.js";
|
||||
import { ALL_MODES } from "./types.js";
|
||||
|
||||
loadDotenv();
|
||||
|
||||
interface CliArgs {
|
||||
taskFilter: string | null;
|
||||
modes: HarvestMode[];
|
||||
repeats: number;
|
||||
outPath: string | null;
|
||||
transcriptsDir: string | null;
|
||||
/** Per-HTTP-call timeout in ms. Default 300_000 (5 min) — reasoner + harvest runs legitimately need this. */
|
||||
timeoutMs: number;
|
||||
dry: boolean;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = {
|
||||
taskFilter: null,
|
||||
modes: [...ALL_MODES],
|
||||
repeats: 1,
|
||||
outPath: null,
|
||||
transcriptsDir: null,
|
||||
timeoutMs: 300_000,
|
||||
dry: false,
|
||||
verbose: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--task") out.taskFilter = argv[++i] ?? null;
|
||||
else if (a === "--mode") {
|
||||
const v = (argv[++i] ?? "").toLowerCase() as HarvestMode;
|
||||
if ((ALL_MODES as string[]).includes(v)) out.modes = [v];
|
||||
} else if (a === "--repeats") out.repeats = Number.parseInt(argv[++i] ?? "1", 10);
|
||||
else if (a === "--out") out.outPath = argv[++i] ?? null;
|
||||
else if (a === "--transcripts-dir") out.transcriptsDir = argv[++i] ?? null;
|
||||
else if (a === "--timeout") {
|
||||
// Accept seconds for humans, convert to ms internally.
|
||||
const secs = Number.parseInt(argv[++i] ?? "300", 10);
|
||||
if (Number.isFinite(secs) && secs > 0) out.timeoutMs = secs * 1000;
|
||||
} else if (a === "--dry") out.dry = true;
|
||||
else if (a === "--verbose" || a === "-v") out.verbose = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function modelForMode(mode: HarvestMode): string {
|
||||
if (mode === "baseline") return "deepseek-chat";
|
||||
return "deepseek-reasoner";
|
||||
}
|
||||
|
||||
function harvestForMode(mode: HarvestMode): boolean {
|
||||
return mode === "reasoner-harvest";
|
||||
}
|
||||
|
||||
async function runOnce(
|
||||
task: HarvestTask,
|
||||
mode: HarvestMode,
|
||||
rep: number,
|
||||
client: DeepSeekClient,
|
||||
transcriptStream: WriteStream | null,
|
||||
): Promise<HarvestRunResult> {
|
||||
const model = modelForMode(mode);
|
||||
const harvest = harvestForMode(mode);
|
||||
|
||||
const prefix = new ImmutablePrefix({ system: task.systemPrompt });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix,
|
||||
model,
|
||||
harvest,
|
||||
stream: false,
|
||||
});
|
||||
const prefixHash = prefix.fingerprint;
|
||||
|
||||
let harvestedTurns = 0;
|
||||
let totalSubgoals = 0;
|
||||
let totalUncertainties = 0;
|
||||
let totalHypotheses = 0;
|
||||
let totalRejectedPaths = 0;
|
||||
let finalText = "";
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
if (transcriptStream) {
|
||||
writeRecord(transcriptStream, {
|
||||
ts: new Date().toISOString(),
|
||||
turn: 1,
|
||||
role: "user",
|
||||
content: task.prompt,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const ev of loop.step(task.prompt)) {
|
||||
if (transcriptStream && ev.role !== "assistant_delta") {
|
||||
writeRecord(transcriptStream, recordFromLoopEvent(ev, { model, prefixHash }));
|
||||
}
|
||||
if (ev.role === "assistant_final") {
|
||||
finalText = ev.content;
|
||||
if (ev.planState) {
|
||||
if (
|
||||
ev.planState.subgoals.length +
|
||||
ev.planState.hypotheses.length +
|
||||
ev.planState.uncertainties.length +
|
||||
ev.planState.rejectedPaths.length >
|
||||
0
|
||||
) {
|
||||
harvestedTurns++;
|
||||
totalSubgoals += ev.planState.subgoals.length;
|
||||
totalHypotheses += ev.planState.hypotheses.length;
|
||||
totalUncertainties += ev.planState.uncertainties.length;
|
||||
totalRejectedPaths += ev.planState.rejectedPaths.length;
|
||||
}
|
||||
}
|
||||
} else if (ev.role === "done") {
|
||||
finalText = ev.content || finalText;
|
||||
break;
|
||||
} else if (ev.role === "error") {
|
||||
throw new Error(ev.error ?? "loop error");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage = (err as Error).message;
|
||||
}
|
||||
|
||||
const verdict = errorMessage
|
||||
? { verdict: "fail" as const, note: errorMessage }
|
||||
: task.check(finalText);
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
mode,
|
||||
repeat: rep + 1,
|
||||
verdict: verdict.verdict,
|
||||
checkNote: verdict.note,
|
||||
finalAgentMessage: finalText,
|
||||
turns: loop.stats.turns.length,
|
||||
cacheHitRatio: loop.stats.aggregateCacheHitRatio,
|
||||
costUsd: loop.stats.totalCost,
|
||||
claudeEquivalentUsd: loop.stats.turns.reduce((s, t) => s + claudeEquivalentCost(t.usage), 0),
|
||||
harvestedTurns,
|
||||
totalSubgoals,
|
||||
totalUncertainties,
|
||||
totalHypotheses,
|
||||
totalRejectedPaths,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function runDry(args: CliArgs): HarvestBenchReport {
|
||||
const tasks = filterTasks(args.taskFilter);
|
||||
const results: HarvestRunResult[] = [];
|
||||
for (const task of tasks) {
|
||||
// Exercise each checker with a deliberately wrong reply so we know
|
||||
// the wiring is live — not a pass on empty.
|
||||
const sanity = task.check("Answer: this is not the right answer at all");
|
||||
console.log(`[${task.id}] checker exercises ok (sanity verdict=${sanity.verdict})`);
|
||||
for (const mode of args.modes) {
|
||||
results.push({
|
||||
taskId: task.id,
|
||||
mode,
|
||||
repeat: 1,
|
||||
verdict: "inconclusive",
|
||||
checkNote: "dry-run (no model call)",
|
||||
finalAgentMessage: "[dry-run]",
|
||||
turns: 0,
|
||||
cacheHitRatio: 0,
|
||||
costUsd: 0,
|
||||
claudeEquivalentUsd: 0,
|
||||
harvestedTurns: 0,
|
||||
totalSubgoals: 0,
|
||||
totalUncertainties: 0,
|
||||
totalHypotheses: 0,
|
||||
totalRejectedPaths: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { meta: buildMeta(args, tasks.length), results };
|
||||
}
|
||||
|
||||
function filterTasks(filter: string | null): HarvestTask[] {
|
||||
if (!filter) return TASKS;
|
||||
const t = TASKS.find((x) => x.id === filter);
|
||||
if (!t) throw new Error(`unknown task: ${filter}`);
|
||||
return [t];
|
||||
}
|
||||
|
||||
function buildMeta(args: CliArgs, taskCount: number): HarvestBenchMeta {
|
||||
return {
|
||||
date: new Date().toISOString(),
|
||||
repeatsPerTask: args.repeats,
|
||||
taskCount,
|
||||
modesRun: args.modes,
|
||||
reasonixVersion: "0.2.2",
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.dry) {
|
||||
const report = runDry(args);
|
||||
writeReport(report, args.outPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new DeepSeekClient({ timeoutMs: args.timeoutMs });
|
||||
const tasks = filterTasks(args.taskFilter);
|
||||
|
||||
if (args.transcriptsDir) mkdirSync(args.transcriptsDir, { recursive: true });
|
||||
|
||||
const results: HarvestRunResult[] = [];
|
||||
for (const task of tasks) {
|
||||
for (let rep = 0; rep < args.repeats; rep++) {
|
||||
for (const mode of args.modes) {
|
||||
let stream: WriteStream | null = null;
|
||||
if (args.transcriptsDir) {
|
||||
const fname = `${task.id}.${mode}.r${rep + 1}.jsonl`;
|
||||
stream = openTranscriptFile(join(args.transcriptsDir, fname), {
|
||||
version: 1,
|
||||
source: `harvest-bench/${mode}`,
|
||||
model: modelForMode(mode),
|
||||
task: task.id,
|
||||
mode,
|
||||
repeat: rep + 1,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
const started = Date.now();
|
||||
let result: HarvestRunResult;
|
||||
try {
|
||||
result = await runOnce(task, mode, rep, client, stream);
|
||||
} finally {
|
||||
stream?.end();
|
||||
}
|
||||
const elapsed = ((Date.now() - started) / 1000).toFixed(1);
|
||||
const note = result.checkNote ? ` ${result.checkNote}` : "";
|
||||
console.log(
|
||||
`[${task.id}/${mode}/r${rep + 1}] verdict=${result.verdict} cache=${(result.cacheHitRatio * 100).toFixed(1)}% cost=$${result.costUsd.toFixed(6)} harvest=${result.harvestedTurns}:${result.totalSubgoals}s/${result.totalUncertainties}u (${elapsed}s)${note}`,
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const report: HarvestBenchReport = { meta: buildMeta(args, tasks.length), results };
|
||||
writeReport(report, args.outPath);
|
||||
}
|
||||
|
||||
function writeReport(report: HarvestBenchReport, outPath: string | null): void {
|
||||
const path =
|
||||
outPath ?? `benchmarks/harvest/results-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||
writeFileSync(path, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
|
||||
function isMain(): boolean {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
return import.meta.url === pathToFileURL(entry).href;
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { main as runHarvestBench };
|
||||
@@ -1,143 +0,0 @@
|
||||
/** Seed reasoning tasks for harvest-bench — single-turn Q/A with deterministic checkers. */
|
||||
|
||||
import type { HarvestTask } from "./types.js";
|
||||
|
||||
const CONCISE_SYSTEM = `You are a precise reasoner. Think carefully, then state your final answer on a clearly-labeled line starting with "Answer:".`;
|
||||
|
||||
/** Parse non-negative integers; expands "2-4" → 2,3,4; strips LaTeX decorations first. */
|
||||
function extractIntegers(text: string): number[] {
|
||||
// Strip LaTeX delimiters so n_1 / n^2 / \{ ... \} don't confuse us.
|
||||
const cleaned = text
|
||||
.replace(/\\[a-zA-Z]+/g, " ") // \frac, \mod etc.
|
||||
.replace(/[{}\\]/g, " ")
|
||||
.replace(/[_^]\d+/g, " "); // subscript/superscript numbers
|
||||
// Expand ranges: "2-4" → "2,3,4" (only for small gaps)
|
||||
const rangeExpanded = cleaned.replace(/(\d+)\s*[-–]\s*(\d+)/g, (_, a, b) => {
|
||||
const lo = Number.parseInt(a, 10);
|
||||
const hi = Number.parseInt(b, 10);
|
||||
if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi - lo > 50) return `${a} ${b}`;
|
||||
const out: number[] = [];
|
||||
for (let i = Math.min(lo, hi); i <= Math.max(lo, hi); i++) out.push(i);
|
||||
return out.join(" ");
|
||||
});
|
||||
const matches = rangeExpanded.match(/-?\d+/g) ?? [];
|
||||
return matches.map((m) => Number.parseInt(m, 10)).filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
/** Extract content after the last "Answer:" label (case-insensitive). */
|
||||
function extractAnswerLine(text: string): string {
|
||||
const matches = [...text.matchAll(/answer\s*[::]\s*([^\n]+)/gi)];
|
||||
if (matches.length === 0) return text;
|
||||
return matches[matches.length - 1]![1]!.trim();
|
||||
}
|
||||
|
||||
/** Positive n ≤ 100 such that n^2 + n + 1 ≡ 0 (mod 7). */
|
||||
const MOD7_EXPECTED: number[] = (() => {
|
||||
const out: number[] = [];
|
||||
for (let n = 1; n <= 100; n++) {
|
||||
if ((n * n + n + 1) % 7 === 0) out.push(n);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
/** Single-integer checker — first plausible int from the last "Answer:" line, exact equality. */
|
||||
function checkSingleInt(expected: number) {
|
||||
return (reply: string) => {
|
||||
const answerLine = extractAnswerLine(reply);
|
||||
const nums = extractIntegers(answerLine);
|
||||
if (nums.length === 0) return { verdict: "fail" as const, note: "no number in Answer line" };
|
||||
const n = nums[0]!;
|
||||
if (n === expected) return { verdict: "pass" as const };
|
||||
return { verdict: "fail" as const, note: `got ${n}, expected ${expected}` };
|
||||
};
|
||||
}
|
||||
|
||||
export const TASKS: HarvestTask[] = [
|
||||
{
|
||||
id: "mod7_list",
|
||||
description:
|
||||
"Number theory — find all positive n ≤ 100 with n^2+n+1 ≡ 0 (mod 7). 29-element set. Classic R1 scratch-work territory.",
|
||||
systemPrompt: CONCISE_SYSTEM,
|
||||
prompt:
|
||||
'Find all positive integers n with n ≤ 100 such that n^2 + n + 1 is divisible by 7. Briefly justify, then end with the full list on a line starting with "Answer:".',
|
||||
check: (reply) => {
|
||||
const answerLine = extractAnswerLine(reply);
|
||||
const found = new Set(extractIntegers(answerLine).filter((n) => n >= 1 && n <= 100));
|
||||
const expected = new Set(MOD7_EXPECTED);
|
||||
if (found.size === 0) {
|
||||
return { verdict: "fail", note: "no integers extracted from Answer line" };
|
||||
}
|
||||
// Exact set equality — both containment directions.
|
||||
for (const n of expected)
|
||||
if (!found.has(n)) return { verdict: "fail", note: `missing n=${n}` };
|
||||
for (const n of found) if (!expected.has(n)) return { verdict: "fail", note: `extra n=${n}` };
|
||||
return { verdict: "pass" };
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "flips_until_3heads",
|
||||
description:
|
||||
"Probability — expected coin flips until 3 consecutive heads. Answer: 14. Tests whether R1 derives via recurrence or via memorized result.",
|
||||
systemPrompt: CONCISE_SYSTEM,
|
||||
prompt:
|
||||
'A fair coin is flipped repeatedly until you see 3 heads in a row. What is the expected number of total flips? Give a single integer on a line starting with "Answer:".',
|
||||
check: (reply) => {
|
||||
const answerLine = extractAnswerLine(reply);
|
||||
const nums = extractIntegers(answerLine);
|
||||
if (nums.length === 0) return { verdict: "fail", note: "no number in Answer line" };
|
||||
// Accept the first sensible number — sometimes agents write "Answer: 14 flips".
|
||||
const n = nums[0]!;
|
||||
if (n === 14) return { verdict: "pass" };
|
||||
return { verdict: "fail", note: `got ${n}, expected 14` };
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "three_hats",
|
||||
description:
|
||||
"Logic — three people wear red/blue hats, at least one red, they're asked in order. First two say no, third says yes. What color? Answer: red (deducible because if third were blue, the first two could see one red + one unknown and wouldn't have said no on their full first round, for most configurations the third can only be certain if red).",
|
||||
systemPrompt: CONCISE_SYSTEM,
|
||||
prompt:
|
||||
'Three people wear hats that are each red or blue. They are told at least one hat is red. Each can see the other two hats but not their own. They are asked in order: "Do you know your hat color?" The first two answer no. The third answers yes. What color is the third person\'s hat? Give a one-word answer on a line starting with "Answer:".',
|
||||
check: (reply) => {
|
||||
const answerLine = extractAnswerLine(reply).toLowerCase();
|
||||
// Accept "red" as a standalone word (not "redirected" etc).
|
||||
if (/\bred\b/.test(answerLine) && !/\bblue\b/.test(answerLine)) {
|
||||
return { verdict: "pass" };
|
||||
}
|
||||
if (/\bblue\b/.test(answerLine)) return { verdict: "fail", note: "answered blue" };
|
||||
return { verdict: "fail", note: "no clear color in Answer line" };
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "pseudoprime_base2",
|
||||
description:
|
||||
"Number theory — smallest composite n > 1 such that 2^n ≡ 2 (mod n). This is the smallest Fermat pseudoprime to base 2. Answer: 341 (= 11·31). V3 frequently answers 561 (Carmichael / wrong base-2 property) or 9 (not actually a pseudoprime to base 2, verifying the model isn't computing). R1 usually gets this right.",
|
||||
systemPrompt: CONCISE_SYSTEM,
|
||||
prompt:
|
||||
'Find the smallest composite integer n > 1 such that 2^n is congruent to 2 modulo n (i.e., n is a Fermat pseudoprime to base 2). Briefly justify, then give a single integer on a line starting with "Answer:".',
|
||||
check: checkSingleInt(341),
|
||||
},
|
||||
|
||||
{
|
||||
id: "derangements_d7",
|
||||
description:
|
||||
"Combinatorics — number of derangements of 7 elements. Answer: 1854 (D_7 = 7! · (1 − 1/1! + 1/2! − 1/3! + … ± 1/7!) = 5040 · 0.367857… = 1854). V3 sometimes approximates via n!/e and rounds wrong, or confuses with D_6=265 or D_8=14833. R1's explicit recurrence D_n = (n-1)(D_{n-1}+D_{n-2}) avoids the mistake.",
|
||||
systemPrompt: CONCISE_SYSTEM,
|
||||
prompt:
|
||||
'How many permutations of {1, 2, 3, 4, 5, 6, 7} have no fixed points? (This is D_7, the derangement number.) Briefly justify, then give a single integer on a line starting with "Answer:".',
|
||||
check: checkSingleInt(1854),
|
||||
},
|
||||
|
||||
{
|
||||
id: "euler_quadratic_break",
|
||||
description:
|
||||
"Number theory — smallest nonneg integer n where n^2 + n + 41 is NOT prime. Euler's prime-generating polynomial famously produces primes for n = 0..39 and first fails at n = 40 (40²+40+41 = 1681 = 41²). V3 often confuses this with the related n²-n+41 (first fails at 41) or cites the wrong threshold. R1 checks small n systematically.",
|
||||
systemPrompt: CONCISE_SYSTEM,
|
||||
prompt:
|
||||
'Consider the polynomial f(n) = n^2 + n + 41 for n = 0, 1, 2, …. What is the smallest nonneg integer n at which f(n) fails to be prime? Briefly justify (including the value of f(n) at that point), then give just the integer n on a line starting with "Answer:".',
|
||||
check: checkSingleInt(40),
|
||||
},
|
||||
];
|
||||
@@ -1,61 +0,0 @@
|
||||
/** Harvest-bench types — single-turn Q/A with deterministic checkers; isolates Pillar 2 the way τ-bench-lite isolates Pillar 1. */
|
||||
|
||||
export type HarvestMode =
|
||||
/** deepseek-chat, no Reasonix tricks. Floor reference for answer quality on reasoning tasks (V3 at its best). */
|
||||
| "baseline"
|
||||
/** deepseek-reasoner, no harvest. Isolates "does reasoner help over chat on these problems?" */
|
||||
| "reasoner"
|
||||
/** deepseek-reasoner + harvest. Isolates "does the extra V3 harvest call add quality / change behavior?" */
|
||||
| "reasoner-harvest";
|
||||
|
||||
export const ALL_MODES: HarvestMode[] = ["baseline", "reasoner", "reasoner-harvest"];
|
||||
|
||||
/** Checker verdict — `inconclusive` reserves room for "right shape, can't verify without running it". */
|
||||
export type CheckVerdict = "pass" | "fail" | "inconclusive";
|
||||
|
||||
export interface HarvestTask {
|
||||
id: string;
|
||||
/** One-line human description, not shown to the model. */
|
||||
description: string;
|
||||
/** System prompt. Kept minimal so harvest signal can be attributed to the model, not to prompt engineering. */
|
||||
systemPrompt: string;
|
||||
/** The single question. */
|
||||
prompt: string;
|
||||
/** Pure + deterministic — no LLM calls, no file I/O. Verdict + optional one-line note. */
|
||||
check: (agentReply: string) => { verdict: CheckVerdict; note?: string };
|
||||
}
|
||||
|
||||
export interface HarvestRunResult {
|
||||
taskId: string;
|
||||
mode: HarvestMode;
|
||||
repeat: number;
|
||||
verdict: CheckVerdict;
|
||||
checkNote?: string;
|
||||
finalAgentMessage: string;
|
||||
/** Turns the loop completed (usually 1 for a single-shot Q/A, > 1 if tool-calls happen). */
|
||||
turns: number;
|
||||
cacheHitRatio: number;
|
||||
costUsd: number;
|
||||
claudeEquivalentUsd: number;
|
||||
/** Harvest-specific — only non-zero on reasoner-harvest mode. */
|
||||
harvestedTurns: number;
|
||||
totalSubgoals: number;
|
||||
totalUncertainties: number;
|
||||
totalHypotheses: number;
|
||||
totalRejectedPaths: number;
|
||||
/** Optional error message if the run crashed (model down, timeout, etc). */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface HarvestBenchMeta {
|
||||
date: string;
|
||||
repeatsPerTask: number;
|
||||
taskCount: number;
|
||||
modesRun: HarvestMode[];
|
||||
reasonixVersion: string;
|
||||
}
|
||||
|
||||
export interface HarvestBenchReport {
|
||||
meta: HarvestBenchMeta;
|
||||
results: HarvestRunResult[];
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,76 @@
|
||||
# Real-world cache hit — single user, single day
|
||||
|
||||
A real Reasonix user shared their DeepSeek dashboard for **2026-05-01**.
|
||||
Used with permission, anonymized.
|
||||
|
||||

|
||||
|
||||
## The numbers
|
||||
|
||||
| | Tokens |
|
||||
|---|---:|
|
||||
| Input — cache hit | 435,033,856 |
|
||||
| Input — cache miss | 767,616 |
|
||||
| Output | 179,763 |
|
||||
| **Day total** | **435,981,235** |
|
||||
|
||||
**Cache hit ratio (input):**
|
||||
`435,033,856 / (435,033,856 + 767,616)` = **99.82%**
|
||||
|
||||
## Cost — using the prices Reasonix bills against (`src/telemetry/stats.ts`)
|
||||
|
||||
USD per 1M tokens — `inputCacheHit / inputCacheMiss / output`:
|
||||
- `deepseek-v4-flash` — `0.028 / 0.139 / 0.278`
|
||||
- `deepseek-v4-pro` — `0.139 / 1.667 / 3.333`
|
||||
|
||||
Assuming **v4-flash** (the project default):
|
||||
|
||||
| | This user (99.82% hit) | Same workload, **0% cache** |
|
||||
|---|---:|---:|
|
||||
| Cache-hit input | $12.18 | — |
|
||||
| Cache-miss input | $0.11 | $60.58 |
|
||||
| Output | $0.05 | $0.05 |
|
||||
| **Total / day** | **$12.34** | **$60.63** |
|
||||
|
||||
→ Cache saved this user **$48.29**, or **~80%** off the un-cached baseline, on a single day.
|
||||
|
||||
On **v4-pro** (5× the prefix-cache discount) the same workload would cost
|
||||
**~$62.35** vs **~$727.08** without cache — a **~91% saving**.
|
||||
|
||||
## "Isn't that just DeepSeek's prefix cache?"
|
||||
|
||||
DeepSeek's API ships prefix caching enabled by default; the *cache* is theirs,
|
||||
the *hit rate* is the client's. Same API, different clients, very different
|
||||
hit rates:
|
||||
|
||||
- DeepSeek's own web chat: 60–80% within a single conversation, drops to 0%
|
||||
on a new session (system prompt may differ).
|
||||
- Cherry Studio / Open WebUI / generic OpenAI-shape SDKs: typically 30–60%
|
||||
on long sessions — history gets reordered, tool specs get re-serialized,
|
||||
every drift breaks the prefix.
|
||||
- Cline / Continue and other XML-tool-call clients: lower still — every tool
|
||||
result inlines into the conversation, shifting bytes the cache keys on.
|
||||
|
||||
99.82% is what falls out of these four design choices in Reasonix:
|
||||
|
||||
1. **`ImmutablePrefix`** (`src/memory.ts`) — system prompt + tool specs are
|
||||
frozen at session start. Same byte sequence every turn.
|
||||
2. **`AppendOnlyLog`** — turns only append. No reorder, no edit-in-place.
|
||||
3. **`VolatileScratch`** — chain-of-thought / per-turn scratch lives outside
|
||||
the cached prefix so it never poisons the next hit.
|
||||
4. **Auto-compact** — when context approaches the cap, older turns fold into
|
||||
a summary message *appended* to the prefix; the prefix itself isn't
|
||||
rewritten, so the cache survives the fold.
|
||||
|
||||
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
|
||||
the bytes cacheable.
|
||||
|
||||
## Reproduce
|
||||
|
||||
The synthetic side of this lives in `benchmarks/tau-bench/` — same task set
|
||||
run through `CacheFirstLoop` vs a deliberately cache-hostile baseline. The
|
||||
real-world data above is what the synthetic numbers look like once a user
|
||||
runs the harness in anger.
|
||||
|
||||
Submit your own dashboard screenshot if you want it anonymized and added
|
||||
here — open an issue.
|
||||
@@ -45,7 +45,7 @@ const PROMPTS = [
|
||||
{ id: "m3", level: "medium", task: "A function \`debounceAsync<T extends any[], R>(fn: (...args: T) => Promise<R>, ms: number): (...args: T) => Promise<R>\` in src/util/debounce.ts. Resolves only the latest call's promise; earlier callers reject with an AbortError-like." },
|
||||
|
||||
// hard (2) — these touch domain types from the repo
|
||||
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`\${file}::\${fullName}\` with source='native'." },
|
||||
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`${file}::${fullName}\` with source='native'." },
|
||||
{ id: "h2", level: "hard", task: "A function \`pairRedGreen(events: Array<{type:string, test_id?:string, status?:string, ts:number}>): Array<{ test_id: string, red_ts: number, green_ts: number }>\` in src/events/pair.ts. For each test_id, find the most recent fail→pass transition and return one entry per test_id. Ignore test_ids that never went green." },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Dashboard ↔ TUI parity
|
||||
|
||||
Closing audit of #369. Inventories every slash command and how the
|
||||
web dashboard handles it.
|
||||
|
||||
## Coverage legend
|
||||
|
||||
- **panel** — has a dedicated SPA panel or modal beyond just typing the
|
||||
slash in the chat box.
|
||||
- **chat-box** — works by typing the slash into the web chat input.
|
||||
Result text shows in the dashboard scrollback as an info row. No
|
||||
dedicated UI; the chat box _is_ the UI.
|
||||
- **tui-only** — keyboard binding or process-controlling action that
|
||||
has no useful web equivalent. Stays a TUI affordance by design.
|
||||
|
||||
## Counts
|
||||
|
||||
Roughly **20 commands have a dedicated panel**, **38 work via the chat
|
||||
box**, and **`/exit` plus 4 raw keyboard shortcuts** stay TUI-only.
|
||||
Aliases (e.g. `/sessions` and `/resume` share one picker) collapse
|
||||
into one row.
|
||||
|
||||
## Core / observability
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/help` | command reference | chat-box |
|
||||
| `/keys` | keyboard shortcuts + prompt prefixes | chat-box |
|
||||
| `/status` | model + flags + ctx + session | chat-box |
|
||||
| `/context` | context-window breakdown (stacked bar) | chat-box · `ctxBreakdown` payload |
|
||||
| `/cost` | last turn / next turn estimate | chat-box · usage card |
|
||||
| `/stats` | cross-session cost dashboard | **panel** · Usage tab |
|
||||
| `/think` | last R1 reasoning dump | chat-box |
|
||||
| `/tool [N]` | dump full output of Nth tool call | chat-box |
|
||||
| `/clear` | wipe visible scrollback | chat-box |
|
||||
| `/new` (`/reset`) | wipe context + scrollback | chat-box |
|
||||
| `/exit` (`/quit`, `/q`) | quit the TUI | **tui-only** |
|
||||
| `/stop` | abort current model turn | chat-box |
|
||||
| `/retry` | resend last user message | chat-box |
|
||||
| `/compact` | fold older turns into summary | chat-box |
|
||||
| `/update` | show current vs latest version | chat-box |
|
||||
| `/doctor` | health check card | chat-box · doctor card |
|
||||
|
||||
## Model & compute
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/preset` | model bundle (auto / flash / pro) | **panel** · Settings → Defaults |
|
||||
| `/effort` | reasoning cap (high / max) | **panel** · Settings → Defaults |
|
||||
| `/model` | active model | **panel** · Settings → Runtime (D-4 #437) |
|
||||
| `/models` | list available models | chat-box |
|
||||
| `/pro` | arm v4-pro for next turn | **panel** · Settings → Compute (D-2 #435) |
|
||||
| `/budget` | session USD cap | **panel** · Settings → Budget + cockpit tile (D-3 #436) |
|
||||
| `/loop` | auto-resubmit on interval | **panel** · Settings → Loop (D-5 #438) |
|
||||
|
||||
## Memory & project
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/memory [list / show / forget / clear]` | manage pinned memory | **panel** · Memory tab |
|
||||
| `/init` | synthesize baseline REASONIX.md | chat-box |
|
||||
| `/semantic` | semantic-search index status | chat-box |
|
||||
| `/search-engine` (`/se`) | switch web search backend | chat-box |
|
||||
| `/language` (`/lang`) | runtime language | **panel** · Settings → Language |
|
||||
|
||||
## Sessions
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/sessions` | list saved sessions | **panel** · SessionPicker modal (C-2 #423) |
|
||||
| `/resume` | open a session | **panel** · same picker |
|
||||
| `/rename` | rename current session | chat-box |
|
||||
| `/forget` | delete current session | chat-box |
|
||||
| `/plans` | active + archived plans | **panel** · Plans tab |
|
||||
| `/replay [N]` | read-only plan archive | **panel** · Viewer modal (C-5 #427) |
|
||||
|
||||
## MCP
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/mcp` (list) | bridged servers + tools | **panel** · MCP tab |
|
||||
| `/mcp browse` | marketplace + install | **panel** · MCP marketplace picker (C-4 #426) |
|
||||
| `/mcp disable` / `enable` / `reconnect` / `text` | server admin | chat-box |
|
||||
| `/resource [uri]` | browse / read MCP resources | chat-box |
|
||||
| `/prompt [name]` | browse / fetch MCP prompts | chat-box |
|
||||
|
||||
## Permissions & admin
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/permissions [list / add / remove / clear]` | shell allowlist | **panel** · Permissions tab |
|
||||
| `/hooks [reload]` | active hooks | **panel** · Hooks tab |
|
||||
| `/dashboard [stop]` | embedded dashboard lifecycle | chat-box · *intentional — admin command for the surface you're typing in* |
|
||||
|
||||
## Code-mode only
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/init [force]` | scan + synthesize REASONIX.md | chat-box |
|
||||
| `/apply [N]` | commit pending edits | chat-box |
|
||||
| `/discard [N]` | drop pending edits | chat-box |
|
||||
| `/walk` | step through pending edits | **panel** · edit-review modal already covered web pre-#369 |
|
||||
| `/undo` | roll back last edit batch | chat-box |
|
||||
| `/history` | edit batch list | chat-box |
|
||||
| `/show [id]` | dump stored edit diff | chat-box |
|
||||
| `/commit "msg"` | git commit | chat-box |
|
||||
| `/checkpoint [name / list / forget]` | snapshot touched files | chat-box |
|
||||
| `/restore` | roll back to checkpoint | **panel** · CheckpointPicker modal (C-3 #425) |
|
||||
| `/plan [on / off]` | read-only plan mode | chat-box |
|
||||
| `/apply-plan` | force-approve pending plan | chat-box |
|
||||
| `/mode [review / auto / yolo]` | edit gate | **panel** · Chat header pill |
|
||||
| `/jobs` | list background jobs | chat-box |
|
||||
| `/kill <id>` | stop background job | chat-box |
|
||||
| `/logs <id> [lines]` | tail job output | chat-box |
|
||||
| `/skill [list / show / new / <name>]` | skill management | **panel** · Skills tab |
|
||||
|
||||
## Keyboard / TTY-native
|
||||
|
||||
These don't have slashes. They ride alongside the slash surface and
|
||||
stay TUI-only:
|
||||
|
||||
- `Esc` — abort current model turn (web equivalent: Abort button in chat)
|
||||
- `Shift+Tab` — cycle edit mode (web equivalent: mode pill in chat header)
|
||||
- `Ctrl-L`, `Ctrl-O`, `space`, `u` — TTY scroll / undo banner / pause hotkeys
|
||||
|
||||
## Done
|
||||
|
||||
Buckets covered:
|
||||
- **C** (#416, closed): pickers — `/sessions`, `/restore`, `/mcp browse`, `/replay` + `/walk` (already)
|
||||
- **D** (#428, closed): settings — `/preset`, `/effort`, `/model`, `/pro`, `/budget`, `/loop`, `/language`
|
||||
|
||||
Bucket A (text outputs that stay chat-box) and bucket B (structured
|
||||
outputs that already have panels) need no further work — the panels
|
||||
shipped in C/D plus the long-standing Memory / Permissions / Hooks /
|
||||
Skills / Plans / Usage tabs already cover every command whose output
|
||||
warranted a dedicated UI.
|
||||
|
||||
This file is the source of truth for the audit. Add a row when a
|
||||
new slash lands.
|
||||
+168
-14
@@ -85,7 +85,7 @@ a:hover { text-decoration: underline; }
|
||||
/* ── Doc chrome ─────────────────────────────────────────────────────────── */
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
@@ -94,18 +94,19 @@ a:hover { text-decoration: underline; }
|
||||
position: sticky; top: 0; align-self: start;
|
||||
height: 100vh; overflow-y: auto;
|
||||
border-right: 1px solid var(--bd);
|
||||
padding: 28px 18px;
|
||||
padding: 28px 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.toc h1 { font-size: 14px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
|
||||
.toc h1 { font-size: 15px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
|
||||
.toc h1 .dot { color: var(--c-brand); margin-right: 8px; }
|
||||
.toc .sub { font-size: 11px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
|
||||
.toc-section { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
|
||||
.toc .sub { font-size: 12px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
|
||||
.toc-section { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
|
||||
.toc-section:first-of-type { margin-top: 0; }
|
||||
.toc ul { list-style: none; padding: 0; margin: 0; }
|
||||
.toc li a {
|
||||
display: block; padding: 3px 8px; margin: 1px 0;
|
||||
color: var(--fg-2); font-size: 12.5px; border-radius: var(--r);
|
||||
display: block; padding: 4px 10px; margin: 1px 0;
|
||||
color: var(--fg-2); font-size: 14px; line-height: 1.4;
|
||||
border-radius: var(--r); overflow-wrap: anywhere;
|
||||
}
|
||||
.toc li a:hover { color: var(--fg-0); background: var(--bg-elev); text-decoration: none; }
|
||||
|
||||
@@ -118,7 +119,7 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
}
|
||||
.section > h2 .num { color: var(--fg-4); margin-right: 10px; font-weight: 500; }
|
||||
.section > .lede {
|
||||
color: var(--fg-2); margin: 0 0 22px; font-size: 13px; max-width: 700px; line-height: 1.6;
|
||||
color: var(--fg-2); margin: 0 0 22px; font-size: 15px; max-width: 720px; line-height: 1.6;
|
||||
}
|
||||
.subsec { margin-bottom: 22px; }
|
||||
.subsec > h3 {
|
||||
@@ -126,8 +127,8 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
margin: 24px 0 4px; letter-spacing: .04em; text-transform: uppercase;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 12px; text-transform: none; letter-spacing: 0; }
|
||||
.subsec > p { color: var(--fg-3); font-size: 12.5px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
|
||||
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 13px; text-transform: none; letter-spacing: 0; }
|
||||
.subsec > p { color: var(--fg-3); font-size: 15px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
|
||||
|
||||
/* "Mock" — a faux-window frame to display dashboard pieces inside the design doc */
|
||||
.mock {
|
||||
@@ -155,11 +156,11 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
.swatch .chip { width: 22px; height: 22px; border-radius: var(--r); flex-shrink: 0; border: 1px solid rgba(255,255,255,.04); }
|
||||
.swatch .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.swatch .name { color: var(--fg-1); font-size: 11.5px; }
|
||||
.swatch .hex { color: var(--fg-3); font-size: 10.5px; }
|
||||
.swatch .hex { color: var(--fg-3); font-size: 11.5px; }
|
||||
|
||||
.scale-row { display: flex; align-items: baseline; gap: 16px; padding: 6px 0; border-bottom: 1px dashed #181b22; }
|
||||
.scale-row:last-child { border-bottom: none; }
|
||||
.scale-row .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); width: 70px; flex-shrink: 0; }
|
||||
.scale-row .lbl { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-3); width: 76px; flex-shrink: 0; }
|
||||
.scale-row .ex { color: var(--fg-1); }
|
||||
|
||||
.glyph-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
|
||||
@@ -311,12 +312,13 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
.pill.acc { color: var(--c-accent); background: rgba(210,168,255,.10); }
|
||||
|
||||
/* Table */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; table-layout: auto; }
|
||||
.tbl th, .tbl td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--bd); }
|
||||
.tbl th { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; color: var(--fg-3); text-transform: uppercase; letter-spacing: .08em; background: var(--bg-elev); }
|
||||
.tbl th.num, .tbl td.num { font-family: var(--font-mono); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.tbl td { color: var(--fg-1); }
|
||||
.tbl tbody tr:hover { background: var(--bg-hover); }
|
||||
.tbl td.num { font-family: var(--font-mono); text-align: right; color: var(--fg-0); font-variant-numeric: tabular-nums; }
|
||||
.tbl td.num { color: var(--fg-0); }
|
||||
.tbl td.dim { color: var(--fg-3); }
|
||||
.tbl td.path { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-2); }
|
||||
|
||||
@@ -812,6 +814,9 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
}
|
||||
.chip-f:hover { background: var(--bg-hover); color: var(--fg-1); }
|
||||
.chip-f.active { color: var(--c-brand); border-color: var(--c-brand); background: rgba(121,192,255,.08); }
|
||||
.chip-f.static { cursor: default; }
|
||||
.chip-f.static:hover { background: var(--bg-elev); color: var(--fg-2); }
|
||||
.chip-f.static.active:hover { color: var(--c-brand); background: rgba(121,192,255,.08); }
|
||||
.chip-f .ct { color: var(--fg-4); font-size: 10px; }
|
||||
.chip-f.active .ct { color: var(--c-brand); }
|
||||
.chip-f .x { color: var(--fg-4); padding: 0 2px; }
|
||||
@@ -2016,6 +2021,155 @@ input:focus, textarea:focus, select:focus { border-color: var(--c-brand); }
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.modal-picker-search {
|
||||
width: 100%;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
color: var(--fg-1);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-picker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
background: var(--bg-elev-2);
|
||||
padding: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-picker-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4px 8px;
|
||||
align-items: baseline;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm, 4px);
|
||||
color: var(--fg-1);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-picker-row:hover {
|
||||
background: var(--bg-elev-3, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
.modal-picker-row.selected {
|
||||
border-color: var(--accent, #fcd34d);
|
||||
background: var(--bg-elev-3, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
.modal-picker-title {
|
||||
grid-column: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-picker-badge {
|
||||
grid-column: 2;
|
||||
font-size: 11px;
|
||||
color: var(--fg-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elev-1, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
.modal-picker-subtitle {
|
||||
grid-column: 1 / 3;
|
||||
font-size: 12px;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.modal-picker-meta {
|
||||
grid-column: 1 / 3;
|
||||
font-size: 11px;
|
||||
color: var(--fg-3, var(--fg-2));
|
||||
}
|
||||
|
||||
.modal-picker-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--fg-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-picker-more {
|
||||
align-self: center;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-picker-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-picker-form input {
|
||||
width: 100%;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
color: var(--fg-1);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-viewer-steps {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modal-viewer-step {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--r-sm, 4px);
|
||||
background: var(--bg-elev-2);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.modal-viewer-step-mark {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.modal-viewer-step-done .modal-viewer-step-mark {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.modal-viewer-step-title {
|
||||
color: var(--fg-1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-viewer-step-done .modal-viewer-step-title {
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.modal-viewer-body {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--fg-1);
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Side-by-side diff for the edit-review modal — left is "before" (red
|
||||
* tint), right is "after" (green tint), context rows render unchanged.
|
||||
* Lines hljs-highlight per the file's language. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { marked } from "marked";
|
||||
import { memo } from "preact/compat";
|
||||
import { useState } from "preact/hooks";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
@@ -104,6 +105,49 @@ interface RevisionSpec {
|
||||
remainingSteps: RevisionStep[];
|
||||
}
|
||||
|
||||
export type PickerActionName =
|
||||
| "pick"
|
||||
| "delete"
|
||||
| "rename"
|
||||
| "new"
|
||||
| "install"
|
||||
| "uninstall"
|
||||
| "load-more"
|
||||
| "refine"
|
||||
| "cancel";
|
||||
|
||||
export interface PickerItemSpec {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: string;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface PickerModalSpec {
|
||||
pickerKind: string;
|
||||
title: string;
|
||||
query?: string;
|
||||
items: PickerItemSpec[];
|
||||
actions: PickerActionName[];
|
||||
hasMore?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ViewerStep {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "done" | "queued";
|
||||
}
|
||||
|
||||
export interface ViewerModalSpec {
|
||||
viewerKind: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
steps?: ViewerStep[];
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
interface DiffEntry {
|
||||
kind: "context" | "ins" | "del";
|
||||
text: string;
|
||||
@@ -257,7 +301,11 @@ export function ToolCard({ msg }: ToolCardProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
export function ChatMessage({ msg, streaming }: ChatMessageProps) {
|
||||
// memo() short-circuits re-renders when shallow props are unchanged.
|
||||
// Historical messages keep stable msg references across deltas, so the
|
||||
// O(N) marked.parse + hljs work that used to fire per assistant_delta
|
||||
// now only runs on truly new messages and the live streaming bubble.
|
||||
export const ChatMessage = memo(function ChatMessage({ msg, streaming }: ChatMessageProps) {
|
||||
const role = msg.role;
|
||||
const glyph = ROLE_GLYPH[role as ChatRole] ?? "·";
|
||||
if (role === "tool") {
|
||||
@@ -278,7 +326,7 @@ export function ChatMessage({ msg, streaming }: ChatMessageProps) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Each component renders a card matching the TUI's ModalCard accent
|
||||
@@ -617,6 +665,220 @@ export function CheckpointModal({ modal, onResolve }: { modal: CheckpointSpec; o
|
||||
`;
|
||||
}
|
||||
|
||||
export function PickerModal({
|
||||
modal,
|
||||
onResolve,
|
||||
}: {
|
||||
modal: PickerModalSpec;
|
||||
onResolve: OnResolve;
|
||||
}) {
|
||||
useLang();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(modal.items[0]?.id ?? null);
|
||||
const [query, setQuery] = useState(modal.query ?? "");
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [renameText, setRenameText] = useState("");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newText, setNewText] = useState("");
|
||||
|
||||
const has = (a: PickerActionName) => modal.actions.includes(a);
|
||||
const selected = modal.items.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
const submitRefine = (next: string) => {
|
||||
setQuery(next);
|
||||
if (has("refine")) onResolve("picker", { action: "refine", query: next });
|
||||
};
|
||||
|
||||
const startRename = (id: string) => {
|
||||
const item = modal.items.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
setRenameTarget(id);
|
||||
setRenameText(item.title);
|
||||
};
|
||||
|
||||
const sendRename = () => {
|
||||
if (!renameTarget || !renameText.trim()) return;
|
||||
onResolve("picker", { action: "rename", id: renameTarget, text: renameText });
|
||||
setRenameTarget(null);
|
||||
setRenameText("");
|
||||
};
|
||||
|
||||
const sendNew = () => {
|
||||
onResolve("picker", newText.trim() ? { action: "new", text: newText } : { action: "new" });
|
||||
setShowNew(false);
|
||||
setNewText("");
|
||||
};
|
||||
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#fcd34d"
|
||||
icon="≡"
|
||||
title=${modal.title}
|
||||
subtitle=${modal.hint}
|
||||
>
|
||||
${
|
||||
has("refine")
|
||||
? html`<input
|
||||
class="modal-picker-search"
|
||||
type="search"
|
||||
placeholder=${t("modal.pickerFilter")}
|
||||
value=${query}
|
||||
onInput=${(e: Event) => submitRefine((e.target as HTMLInputElement).value)}
|
||||
/>`
|
||||
: null
|
||||
}
|
||||
<div class="modal-picker-list">
|
||||
${
|
||||
modal.items.length === 0
|
||||
? html`<div class="modal-picker-empty">${t("modal.pickerEmpty")}</div>`
|
||||
: modal.items.map(
|
||||
(it) => html`
|
||||
<button
|
||||
key=${it.id}
|
||||
class=${`modal-picker-row${it.id === selectedId ? " selected" : ""}`}
|
||||
onClick=${() => setSelectedId(it.id)}
|
||||
onDblClick=${() => has("pick") && onResolve("picker", { action: "pick", id: it.id })}
|
||||
>
|
||||
<span class="modal-picker-title">${it.title}</span>
|
||||
${it.badge ? html`<span class="modal-picker-badge">${it.badge}</span>` : null}
|
||||
${it.subtitle ? html`<span class="modal-picker-subtitle">${it.subtitle}</span>` : null}
|
||||
${it.meta ? html`<span class="modal-picker-meta">${it.meta}</span>` : null}
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
${
|
||||
modal.hasMore && has("load-more")
|
||||
? html`<button
|
||||
class="modal-picker-more"
|
||||
onClick=${() => onResolve("picker", { action: "load-more" })}
|
||||
>${t("modal.pickerLoadMore")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
renameTarget
|
||||
? html`
|
||||
<div class="modal-picker-form">
|
||||
<input
|
||||
type="text"
|
||||
value=${renameText}
|
||||
onInput=${(e: Event) => setRenameText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${sendRename} disabled=${!renameText.trim()}>${t("common.save")}</button>
|
||||
<button onClick=${() => setRenameTarget(null)}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: showNew
|
||||
? html`
|
||||
<div class="modal-picker-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("modal.pickerNewPlaceholder")}
|
||||
value=${newText}
|
||||
onInput=${(e: Event) => setNewText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${sendNew}>${t("common.add")}</button>
|
||||
<button onClick=${() => setShowNew(false)}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="modal-actions">
|
||||
${
|
||||
has("pick") && selected
|
||||
? html`<button
|
||||
class="primary"
|
||||
onClick=${() => onResolve("picker", { action: "pick", id: selected.id })}
|
||||
>${t("modal.pickerPick")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("install") && selected
|
||||
? html`<button
|
||||
class="primary"
|
||||
onClick=${() => onResolve("picker", { action: "install", id: selected.id })}
|
||||
>${t("modal.pickerInstall")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("uninstall") && selected
|
||||
? html`<button
|
||||
onClick=${() => onResolve("picker", { action: "uninstall", id: selected.id })}
|
||||
>${t("modal.pickerUninstall")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("rename") && selected
|
||||
? html`<button onClick=${() => startRename(selected.id)}>${t("modal.pickerRename")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("delete") && selected
|
||||
? html`<button
|
||||
class="danger"
|
||||
onClick=${() => onResolve("picker", { action: "delete", id: selected.id })}
|
||||
>${t("common.delete")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("new")
|
||||
? html`<button onClick=${() => setShowNew(true)}>${t("modal.pickerNew")}</button>`
|
||||
: null
|
||||
}
|
||||
<button onClick=${() => onResolve("picker", { action: "cancel" })}>${t("modal.cancel")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function ViewerModal({
|
||||
modal,
|
||||
onResolve,
|
||||
}: {
|
||||
modal: ViewerModalSpec;
|
||||
onResolve: OnResolve;
|
||||
}) {
|
||||
useLang();
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#67e8f9"
|
||||
icon="◇"
|
||||
title=${modal.title}
|
||||
subtitle=${modal.meta}
|
||||
>
|
||||
${
|
||||
modal.steps && modal.steps.length > 0
|
||||
? html`
|
||||
<ol class="modal-viewer-steps">
|
||||
${modal.steps.map(
|
||||
(s: ViewerStep) => html`
|
||||
<li key=${s.id} class=${`modal-viewer-step modal-viewer-step-${s.status}`}>
|
||||
<span class="modal-viewer-step-mark">${s.status === "done" ? "✓" : "·"}</span>
|
||||
<span class="modal-viewer-step-title">${s.title}</span>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</ol>
|
||||
`
|
||||
: null
|
||||
}
|
||||
${
|
||||
modal.body
|
||||
? html`<div class="md modal-viewer-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body) }}></div>`
|
||||
: null
|
||||
}
|
||||
<div class="modal-actions">
|
||||
<button onClick=${() => onResolve("viewer", { action: "close" })}>${t("modal.viewerClose")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function RevisionModal({ modal, onResolve }: { modal: RevisionSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const riskColor = (r: string | undefined) =>
|
||||
|
||||
+106
-20
@@ -61,8 +61,36 @@ export const en = {
|
||||
effortHigh: "high (cheaper / faster)",
|
||||
webSearch: "web search",
|
||||
webSearchNote: "web_fetch + web_search tools",
|
||||
sectionCompute: "Compute",
|
||||
proNext: "/pro one-shot",
|
||||
proArm: "Arm for next turn",
|
||||
proArmed: "Armed — disarms after next turn",
|
||||
proNextNote: "next turn runs on deepseek-v4-pro, then auto-disarms",
|
||||
sectionBudget: "Budget",
|
||||
budgetOf: "of",
|
||||
budgetSetCap: "set a cap",
|
||||
budgetCustom: "custom",
|
||||
budgetBumpHint: "bump the cap to keep going",
|
||||
budgetClear: "Clear cap",
|
||||
budgetIdleLine: "warns at 80% · refuses past 100%",
|
||||
budgetWarnLine: "approaching cap — loop will refuse past 100%",
|
||||
budgetRefusing: "cap exhausted — next turn refused until bumped or cleared",
|
||||
sectionLoop: "Loop",
|
||||
loopIdleHint: "Auto-resubmit a prompt on a fixed interval.",
|
||||
loopCostHint: "Each iteration costs ~{cost} (last turn).",
|
||||
loopInterval: "interval",
|
||||
loopCustom: "custom",
|
||||
loopRangeError: "interval must fall in 5s..6h",
|
||||
loopPrompt: "prompt",
|
||||
loopPromptPlaceholder: "e.g. check the deploy status and report any errors",
|
||||
loopStart: "Start loop",
|
||||
loopStop: "Stop",
|
||||
loopRunning: "running",
|
||||
loopIter: "iter {iter}",
|
||||
loopFiresIn: "fires in {remaining}",
|
||||
sectionRuntime: "Runtime",
|
||||
activeModel: "active model",
|
||||
modelPricingLine: "${hit} hit · ${miss} miss · ${out} out per 1M tok",
|
||||
editMode: "edit mode",
|
||||
editModeNote: "switch from the Chat tab header",
|
||||
sectionLanguage: "Language",
|
||||
@@ -89,7 +117,8 @@ export const en = {
|
||||
clearFailed: "/clear failed: {error}",
|
||||
eventStreamError: "event stream interrupted — reconnecting…",
|
||||
semanticBanner: "Semantic search isn't enabled for this project.",
|
||||
semanticBannerDesc: "Build the index once and the model can find code by meaning (\"where do we handle auth failures?\") instead of grep on exact strings.",
|
||||
semanticBannerDesc:
|
||||
'Build the index once and the model can find code by meaning ("where do we handle auth failures?") instead of grep on exact strings.',
|
||||
semanticBannerBtn: "Build it →",
|
||||
semanticBannerDismiss: "dismiss (don't show again)",
|
||||
slashCommands: "slash commands",
|
||||
@@ -98,9 +127,12 @@ export const en = {
|
||||
effortMaxTitle: "max (default — best quality)",
|
||||
effortHighTitle: "high (cheaper / faster)",
|
||||
presetTitle: "preset — model commitment",
|
||||
presetAutoTitle: "auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)",
|
||||
presetFlashTitle: "flash — always flash; no auto-escalate. /pro still works for one-shot manual",
|
||||
presetProTitle: "pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work.",
|
||||
presetAutoTitle:
|
||||
"auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)",
|
||||
presetFlashTitle:
|
||||
"flash — always flash; no auto-escalate. /pro still works for one-shot manual",
|
||||
presetProTitle:
|
||||
"pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work.",
|
||||
editGateTitle: "edit gate — Shift+Tab cycles in TUI",
|
||||
editReviewTitle: "review — both edits and non-allowlisted shell ask first",
|
||||
editAutoTitle: "auto — edits auto-apply, shell still asks",
|
||||
@@ -139,12 +171,14 @@ export const en = {
|
||||
loading: "loading overview…",
|
||||
failed: "overview failed: {error}",
|
||||
standaloneTitle: "Standalone mode",
|
||||
standaloneDesc: "Read-only disk view. Start /dashboard from inside reasonix code for live session state, MCP, and tools.",
|
||||
standaloneDesc:
|
||||
"Read-only disk view. Start /dashboard from inside reasonix code for live session state, MCP, and tools.",
|
||||
cockpit: "Cockpit",
|
||||
balance: "balance",
|
||||
tokens7d: "tokens · 7d",
|
||||
cacheHit: "cache hit",
|
||||
toolCalls24h: "tool calls · 24h",
|
||||
budget: "budget",
|
||||
currentSession: "current session",
|
||||
noSession: "No live session — /dashboard from inside reasonix code to attach.",
|
||||
promptTok: "prompt tok",
|
||||
@@ -180,12 +214,19 @@ export const en = {
|
||||
dailyMeta: "cost · cache saved · turns",
|
||||
noData: "No usage data yet — run a turn in reasonix chat / code / run and refresh.",
|
||||
windows: "Rolling windows",
|
||||
colWindow: "window",
|
||||
colTurns: "turns",
|
||||
colCacheHit: "cache hit",
|
||||
colCost: "cost (USD)",
|
||||
colCacheSaved: "cache saved",
|
||||
colVsClaude: "vs Claude",
|
||||
colSaved: "saved",
|
||||
axisTime: "time",
|
||||
axisUsd: "USD",
|
||||
axisTurns: "turns",
|
||||
seriesCost: "cost",
|
||||
seriesCacheSaved: "cache saved",
|
||||
seriesTurns: "turns",
|
||||
mostUsed: "Most used models",
|
||||
colModel: "model",
|
||||
},
|
||||
@@ -197,7 +238,8 @@ export const en = {
|
||||
msgs: "msgs",
|
||||
pickHint: "Pick a session on the left to read its transcript.",
|
||||
resumeTitle: "Resume in TUI",
|
||||
resumeDesc: "Mid-session swap requires a restart so the message log can rewind cleanly. Quit your current session, then run:",
|
||||
resumeDesc:
|
||||
"Mid-session swap requires a restart so the message log can rewind cleanly. Quit your current session, then run:",
|
||||
loadingTranscript: "loading transcript…",
|
||||
emptyTranscript: "empty transcript.",
|
||||
messages: "{count} message{s}",
|
||||
@@ -218,24 +260,26 @@ export const en = {
|
||||
loading: "loading permissions…",
|
||||
failed: "permissions failed: {error}",
|
||||
yoloTitle: "YOLO mode",
|
||||
yoloDesc: "Every shell command auto-runs, allowlist bypassed. Switch back with /mode review in the TUI.",
|
||||
yoloDesc:
|
||||
"Every shell command auto-runs, allowlist bypassed. Switch back with /mode review in the TUI.",
|
||||
project: "project",
|
||||
builtin: "builtin",
|
||||
addPrefix: "add a prefix",
|
||||
addPlaceholder: "e.g. \"npm run build\" or \"deploy.sh\"",
|
||||
addPlaceholder: 'e.g. "npm run build" or "deploy.sh"',
|
||||
clearAll: "Clear all",
|
||||
alreadyIn: "{prefix} already in list",
|
||||
added: "added: {prefix}",
|
||||
removed: "removed: {prefix}",
|
||||
cleared: "cleared {count} entr{y}",
|
||||
removeConfirm: "Remove \"{prefix}\" from this project's allowlist?",
|
||||
removeConfirm: 'Remove "{prefix}" from this project\'s allowlist?',
|
||||
clearConfirm: "Wipe every project allowlist entry? Builtin entries are unaffected.",
|
||||
projectAllowlist: "Project allowlist · {count}",
|
||||
nothingStored: "Nothing stored yet for this project.",
|
||||
colNum: "#",
|
||||
colPrefix: "prefix",
|
||||
builtinTitle: "Builtin · {count} · read-only",
|
||||
standaloneWarning: "Mutations require /dashboard from inside an active reasonix code session — standalone reasonix dashboard can't tell which project's allowlist to edit.",
|
||||
standaloneWarning:
|
||||
"Mutations require /dashboard from inside an active reasonix code session — standalone reasonix dashboard can't tell which project's allowlist to edit.",
|
||||
},
|
||||
mcp: {
|
||||
loading: "loading MCP…",
|
||||
@@ -255,7 +299,8 @@ export const en = {
|
||||
removeBtn: "Remove",
|
||||
spec: "spec",
|
||||
whyUnbridged: "Why unbridged?",
|
||||
whyUnbridgedDesc: "This spec lives in your config.json but isn't bridged into the live session. MCP servers attach when reasonix code starts; the dashboard alone can't spawn the child process.",
|
||||
whyUnbridgedDesc:
|
||||
"This spec lives in your config.json but isn't bridged into the live session. MCP servers attach when reasonix code starts; the dashboard alone can't spawn the child process.",
|
||||
whyUnbridgedHint: "To activate: restart reasonix code, then refresh this dashboard.",
|
||||
pickHint: "Pick an MCP server on the left to inspect tools / resources / prompts.",
|
||||
toolsTitle: "Tools · {count}",
|
||||
@@ -283,13 +328,17 @@ export const en = {
|
||||
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.",
|
||||
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).",
|
||||
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…",
|
||||
@@ -332,7 +381,8 @@ export const en = {
|
||||
runs7d: "runs · 7d",
|
||||
pickHint: "Pick a skill on the left, or create a new one above.",
|
||||
readOnlyBuiltin: "read-only · builtin",
|
||||
builtinDesc: "Built-in skills ship with Reasonix; the model picks them up automatically. To customize, create a project- or global-scoped skill with the same name.",
|
||||
builtinDesc:
|
||||
"Built-in skills ship with Reasonix; the model picks them up automatically. To customize, create a project- or global-scoped skill with the same name.",
|
||||
saved: "saved {scope}/{name}",
|
||||
deleteConfirm: "Delete skill {scope}/{name}?",
|
||||
reloadHint: "re-loaded on next /new or session restart",
|
||||
@@ -375,6 +425,7 @@ export const en = {
|
||||
filterPlaceholder: "filter plans",
|
||||
active: "active",
|
||||
done: "done",
|
||||
idle: "idle",
|
||||
steps: "steps",
|
||||
pickHint: "Pick a plan on the left.",
|
||||
noTitle: "(no title)",
|
||||
@@ -388,11 +439,13 @@ export const en = {
|
||||
ready: "ready",
|
||||
setupNeeded: "setup needed",
|
||||
installOllama: "Install Ollama",
|
||||
installOllamaDesc: "Reasonix doesn't run package managers for you. Install Ollama first, then come back:",
|
||||
installOllamaDesc:
|
||||
"Reasonix doesn't run package managers for you. Install Ollama first, then come back:",
|
||||
macWindows: "macOS / Windows:",
|
||||
download: "download from ollama.com/download",
|
||||
linux: "Linux:",
|
||||
refreshHint: "Refresh after install — this panel will offer to start the daemon and pull {model}.",
|
||||
refreshHint:
|
||||
"Refresh after install — this panel will offer to start the daemon and pull {model}.",
|
||||
daemon: "Daemon",
|
||||
daemonDesc: "ollama is on your PATH but the HTTP daemon isn't reachable.",
|
||||
startDaemon: "Start daemon",
|
||||
@@ -404,16 +457,35 @@ export const en = {
|
||||
pullModel: "Pull {model}",
|
||||
indexStatus: "index status",
|
||||
builtStatus: "● built",
|
||||
incompatibleStatus: "● incompatible",
|
||||
chunks: "chunks",
|
||||
files: "files",
|
||||
dim: "dim",
|
||||
size: "size",
|
||||
lastBuild: "last build",
|
||||
builtWith: "built with",
|
||||
currentTarget: "current target",
|
||||
incompatibleHint: "This on-disk index was built for a different provider or model. Run Rebuild to replace it.",
|
||||
runIndexHint: "Run an index to enable semantic_search.",
|
||||
reIndex: "Re-index",
|
||||
build: "Build",
|
||||
rebuild: "Rebuild",
|
||||
stop: "Stop",
|
||||
provider: "provider",
|
||||
providerType: "service type",
|
||||
openaiCompat: "openai-compatible",
|
||||
apiUrl: "API URL",
|
||||
apiKey: "API key",
|
||||
apiKeyStoredNote: "API key is stored in ~/.reasonix/config.json — do not share that file.",
|
||||
customRequestBody: "custom request body",
|
||||
invalidCustomRequestBody: "Custom request body must be valid JSON: {error}",
|
||||
customRequestBodyMustBeObject: "Custom request body must be a JSON object.",
|
||||
saveBeforeIndex: "Save semantic settings before starting an index.",
|
||||
extraBody: "extra body",
|
||||
keepExistingKey: "leave blank to keep existing key",
|
||||
remoteProvider: "Remote embedding provider",
|
||||
remoteProviderDesc:
|
||||
"Configure the full OpenAI-compatible embeddings URL here. Reasonix will send requests exactly to the URL you provide.",
|
||||
ollama: "ollama",
|
||||
binary: "binary",
|
||||
found: "found",
|
||||
@@ -441,11 +513,14 @@ export const en = {
|
||||
nothingSkipped: "nothing skipped — all walked files would be indexed.",
|
||||
firstIncluded: "first {count} included file(s)",
|
||||
job: "Job",
|
||||
phaseSetup: "preparing",
|
||||
phaseScan: "scanning files",
|
||||
phaseEmbed: "embedding chunks",
|
||||
phaseWrite: "writing index",
|
||||
phaseDone: "done",
|
||||
phaseError: "error",
|
||||
phaseCancelled: "cancelled",
|
||||
setupFailed: "setup failed",
|
||||
stopping: "stopping",
|
||||
scanned: "scanned {count}",
|
||||
changed: "changed {count}",
|
||||
@@ -473,7 +548,7 @@ export const en = {
|
||||
shellSubtitle: "model wants to run a shell command",
|
||||
shellBgSubtitle: "long-running — keeps running after approval",
|
||||
runOnce: "Run once",
|
||||
alwaysAllow: "Always allow \"{prefix}\"",
|
||||
alwaysAllow: 'Always allow "{prefix}"',
|
||||
deny: "Deny",
|
||||
choiceTitle: "model wants you to pick",
|
||||
typeOwn: "Type my own answer",
|
||||
@@ -484,7 +559,8 @@ export const en = {
|
||||
cancelSummary: "Drop the question. Model will ask what you actually want.",
|
||||
planTitle: "plan submitted",
|
||||
planSubtitle: "model proposed a plan; review then pick",
|
||||
approveInstructions: "Optional last instructions / answers to open questions (Enter to send blank)",
|
||||
approveInstructions:
|
||||
"Optional last instructions / answers to open questions (Enter to send blank)",
|
||||
refinePlaceholder: "What needs to change? Be specific.",
|
||||
approve: "Approve",
|
||||
refine: "Refine",
|
||||
@@ -507,5 +583,15 @@ export const en = {
|
||||
reject: "Reject",
|
||||
arguments: "arguments",
|
||||
revisePlaceholder: "What needs to change before the next step? Leave blank to just continue.",
|
||||
pickerFilter: "Filter…",
|
||||
pickerEmpty: "Nothing to show.",
|
||||
pickerLoadMore: "Load more",
|
||||
pickerPick: "Open",
|
||||
pickerInstall: "Install",
|
||||
pickerUninstall: "Uninstall",
|
||||
pickerRename: "Rename…",
|
||||
pickerNew: "New…",
|
||||
pickerNewPlaceholder: "Name (leave blank for default)",
|
||||
viewerClose: "Close",
|
||||
},
|
||||
};
|
||||
+87
-11
@@ -61,8 +61,36 @@ export const zhCN = {
|
||||
effortHigh: "high(更便宜 / 更快)",
|
||||
webSearch: "网页搜索",
|
||||
webSearchNote: "web_fetch + web_search 工具",
|
||||
sectionCompute: "计算",
|
||||
proNext: "/pro 单轮",
|
||||
proArm: "为下一轮装备",
|
||||
proArmed: "已装备 — 下一轮后自动解除",
|
||||
proNextNote: "下一轮使用 deepseek-v4-pro,之后自动解除",
|
||||
sectionBudget: "预算",
|
||||
budgetOf: "/",
|
||||
budgetSetCap: "设置上限",
|
||||
budgetCustom: "自定义",
|
||||
budgetBumpHint: "提高上限以继续",
|
||||
budgetClear: "清除上限",
|
||||
budgetIdleLine: "80% 时提醒 · 100% 后拒绝执行",
|
||||
budgetWarnLine: "接近上限 — 超过 100% 将拒绝执行",
|
||||
budgetRefusing: "已超出上限 — 提高或清除后才会继续",
|
||||
sectionLoop: "循环",
|
||||
loopIdleHint: "按固定间隔自动重新提交一段提示词。",
|
||||
loopCostHint: "每次迭代约 {cost}(上一轮成本)。",
|
||||
loopInterval: "间隔",
|
||||
loopCustom: "自定义",
|
||||
loopRangeError: "间隔需在 5s..6h 之间",
|
||||
loopPrompt: "提示词",
|
||||
loopPromptPlaceholder: "例如:检查部署状态并汇报任何错误",
|
||||
loopStart: "启动循环",
|
||||
loopStop: "停止",
|
||||
loopRunning: "运行中",
|
||||
loopIter: "第 {iter} 次",
|
||||
loopFiresIn: "{remaining} 后触发",
|
||||
sectionRuntime: "运行时",
|
||||
activeModel: "当前模型",
|
||||
modelPricingLine: "${hit} 命中 · ${miss} 未命中 · ${out} 输出 / 100 万 tok",
|
||||
editMode: "编辑模式",
|
||||
editModeNote: "在对话标签页头部切换",
|
||||
sectionLanguage: "语言",
|
||||
@@ -89,7 +117,8 @@ export const zhCN = {
|
||||
clearFailed: "/clear 失败:{error}",
|
||||
eventStreamError: "事件流中断 — 正在重连…",
|
||||
semanticBanner: "此项目未启用语义搜索。",
|
||||
semanticBannerDesc: "构建一次索引,模型即可按含义查找代码(“哪里处理认证失败?”),而不仅依赖精确字符串的 grep。",
|
||||
semanticBannerDesc:
|
||||
"构建一次索引,模型即可按含义查找代码(“哪里处理认证失败?”),而不仅依赖精确字符串的 grep。",
|
||||
semanticBannerBtn: "构建 →",
|
||||
semanticBannerDismiss: "关闭(不再显示)",
|
||||
slashCommands: "斜杠命令",
|
||||
@@ -139,12 +168,14 @@ export const zhCN = {
|
||||
loading: "加载概览…",
|
||||
failed: "概览失败:{error}",
|
||||
standaloneTitle: "独立模式",
|
||||
standaloneDesc: "只读磁盘视图。在 reasonix code 内启动 /dashboard 以获取实时会话状态、MCP 和工具。",
|
||||
standaloneDesc:
|
||||
"只读磁盘视图。在 reasonix code 内启动 /dashboard 以获取实时会话状态、MCP 和工具。",
|
||||
cockpit: "驾驶舱",
|
||||
balance: "余额",
|
||||
tokens7d: "tokens · 7 天",
|
||||
cacheHit: "缓存命中",
|
||||
toolCalls24h: "工具调用 · 24 小时",
|
||||
budget: "预算",
|
||||
currentSession: "当前会话",
|
||||
noSession: "无活跃会话 — 在 reasonix code 内执行 /dashboard 进行连接。",
|
||||
promptTok: "提示 tokens",
|
||||
@@ -180,12 +211,19 @@ export const zhCN = {
|
||||
dailyMeta: "费用 · 缓存节省 · 轮次",
|
||||
noData: "暂无用量数据 — 在 reasonix chat / code / run 中执行一轮,然后刷新。",
|
||||
windows: "滚动窗口",
|
||||
colWindow: "时间范围",
|
||||
colTurns: "轮次",
|
||||
colCacheHit: "缓存命中",
|
||||
colCost: "费用 (USD)",
|
||||
colCacheSaved: "缓存节省",
|
||||
colVsClaude: "对比 Claude",
|
||||
colSaved: "节省",
|
||||
axisTime: "时间",
|
||||
axisUsd: "美元",
|
||||
axisTurns: "轮次",
|
||||
seriesCost: "费用",
|
||||
seriesCacheSaved: "缓存节省",
|
||||
seriesTurns: "轮次",
|
||||
mostUsed: "最常用模型",
|
||||
colModel: "模型",
|
||||
},
|
||||
@@ -222,20 +260,21 @@ export const zhCN = {
|
||||
project: "项目",
|
||||
builtin: "内置",
|
||||
addPrefix: "添加前缀",
|
||||
addPlaceholder: "例如 \"npm run build\" 或 \"deploy.sh\"",
|
||||
addPlaceholder: '例如 "npm run build" 或 "deploy.sh"',
|
||||
clearAll: "清除全部",
|
||||
alreadyIn: "{prefix} 已在列表中",
|
||||
added: "已添加:{prefix}",
|
||||
removed: "已移除:{prefix}",
|
||||
cleared: "已清除 {count} 条",
|
||||
removeConfirm: "从此项目的允许列表中移除 \"{prefix}\"?",
|
||||
removeConfirm: '从此项目的允许列表中移除 "{prefix}"?',
|
||||
clearConfirm: "清除所有项目允许列表条目?内置条目不受影响。",
|
||||
projectAllowlist: "项目允许列表 · {count}",
|
||||
nothingStored: "此项目暂无存储的条目。",
|
||||
colNum: "#",
|
||||
colPrefix: "前缀",
|
||||
builtinTitle: "内置 · {count} · 只读",
|
||||
standaloneWarning: "修改操作需要在活跃的 reasonix code 会话内执行 /dashboard — 独立模式的 reasonix dashboard 无法确定要编辑哪个项目的允许列表。",
|
||||
standaloneWarning:
|
||||
"修改操作需要在活跃的 reasonix code 会话内执行 /dashboard — 独立模式的 reasonix dashboard 无法确定要编辑哪个项目的允许列表。",
|
||||
},
|
||||
mcp: {
|
||||
loading: "加载 MCP…",
|
||||
@@ -255,7 +294,8 @@ export const zhCN = {
|
||||
removeBtn: "移除",
|
||||
spec: "规格",
|
||||
whyUnbridged: "为什么未桥接?",
|
||||
whyUnbridgedDesc: "此规格存在于您的 config.json 中,但未桥接到实时会话。MCP 服务器在 reasonix code 启动时连接;仪表盘本身无法生成子进程。",
|
||||
whyUnbridgedDesc:
|
||||
"此规格存在于您的 config.json 中,但未桥接到实时会话。MCP 服务器在 reasonix code 启动时连接;仪表盘本身无法生成子进程。",
|
||||
whyUnbridgedHint: "激活方法:重启 reasonix code,然后刷新此仪表盘。",
|
||||
pickHint: "选择左侧的 MCP 服务器以检查工具 / 资源 / 提示。",
|
||||
toolsTitle: "工具 · {count}",
|
||||
@@ -283,13 +323,16 @@ export const zhCN = {
|
||||
marketplaceAlready: "已安装过",
|
||||
marketplaceNeedsEnv: "需设置环境变量:{names}",
|
||||
marketplaceSourceTag: "[{source}]",
|
||||
marketplaceNoInstall: "Smithery 列表项 — 不暴露安装元数据;请直接 `npx -y @smithery/cli install {name}`",
|
||||
marketplaceFetchOnInstall: "Smithery 列表 — 安装时再拉详情。HTTP 服务映射为 streamable-http 远端;stdio 服务通过 @smithery/cli 运行。",
|
||||
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` 后服务器才会真正桥接(热重载在路线图上)。",
|
||||
marketplaceRestartHint:
|
||||
"已写入 ~/.reasonix/config.json。重启 `reasonix code` 后服务器才会真正桥接(热重载在路线图上)。",
|
||||
},
|
||||
memory: {
|
||||
loading: "加载记忆…",
|
||||
@@ -332,7 +375,8 @@ export const zhCN = {
|
||||
runs7d: "次运行 · 7 天",
|
||||
pickHint: "选择左侧的技能,或在上方创建新技能。",
|
||||
readOnlyBuiltin: "只读 · 内置",
|
||||
builtinDesc: "内置技能随 Reasonix 一起发布;模型会自动识别。如需自定义,请创建同名的项目或全局技能。",
|
||||
builtinDesc:
|
||||
"内置技能随 Reasonix 一起发布;模型会自动识别。如需自定义,请创建同名的项目或全局技能。",
|
||||
saved: "已保存 {scope}/{name}",
|
||||
deleteConfirm: "删除技能 {scope}/{name}?",
|
||||
reloadHint: "在下次 /new 或会话重启时重新加载",
|
||||
@@ -375,6 +419,7 @@ export const zhCN = {
|
||||
filterPlaceholder: "筛选计划",
|
||||
active: "进行中",
|
||||
done: "已完成",
|
||||
idle: "未开始",
|
||||
steps: "步骤",
|
||||
pickHint: "选择左侧的计划。",
|
||||
noTitle: "(无标题)",
|
||||
@@ -404,16 +449,34 @@ export const zhCN = {
|
||||
pullModel: "拉取 {model}",
|
||||
indexStatus: "索引状态",
|
||||
builtStatus: "● 已构建",
|
||||
incompatibleStatus: "● 不兼容",
|
||||
chunks: "分块",
|
||||
files: "文件",
|
||||
dim: "维度",
|
||||
size: "大小",
|
||||
lastBuild: "上次构建",
|
||||
builtWith: "构建来源",
|
||||
currentTarget: "当前目标",
|
||||
incompatibleHint: "磁盘上的这个索引是为不同的 provider 或 model 构建的。运行“完全重建”即可替换。",
|
||||
runIndexHint: "运行索引以启用 semantic_search。",
|
||||
reIndex: "重建索引",
|
||||
build: "构建",
|
||||
rebuild: "完全重建",
|
||||
stop: "停止",
|
||||
provider: "提供方",
|
||||
providerType: "服务类型",
|
||||
openaiCompat: "OpenAI-Compatible",
|
||||
apiUrl: "API URL",
|
||||
apiKey: "API Key",
|
||||
customRequestBody: "自定义请求体",
|
||||
invalidCustomRequestBody: "自定义请求体必须是合法 JSON:{error}",
|
||||
customRequestBodyMustBeObject: "自定义请求体必须是 JSON 对象。",
|
||||
saveBeforeIndex: "请先保存语义设置,再启动索引。",
|
||||
extraBody: "扩展请求体",
|
||||
keepExistingKey: "留空则保留现有 Key",
|
||||
remoteProvider: "远程向量服务",
|
||||
remoteProviderDesc:
|
||||
"在这里配置 OpenAI-Compatible embeddings 的完整 URL。Reasonix 会严格使用你提供的 URL 发起请求。",
|
||||
ollama: "Ollama",
|
||||
binary: "二进制",
|
||||
found: "已找到",
|
||||
@@ -441,11 +504,14 @@ export const zhCN = {
|
||||
nothingSkipped: "无跳过 — 所有遍历的文件都将被索引。",
|
||||
firstIncluded: "前 {count} 个包含的文件",
|
||||
job: "任务",
|
||||
phaseSetup: "初始化中",
|
||||
phaseScan: "扫描文件",
|
||||
phaseEmbed: "嵌入分块",
|
||||
phaseWrite: "写入索引",
|
||||
phaseDone: "完成",
|
||||
phaseError: "错误",
|
||||
phaseCancelled: "已停止",
|
||||
setupFailed: "初始化失败",
|
||||
stopping: "停止中",
|
||||
scanned: "已扫描 {count}",
|
||||
changed: "已变更 {count}",
|
||||
@@ -473,7 +539,7 @@ export const zhCN = {
|
||||
shellSubtitle: "模型想要运行一条 Shell 命令",
|
||||
shellBgSubtitle: "长时间运行 — 批准后继续运行",
|
||||
runOnce: "运行一次",
|
||||
alwaysAllow: "始终允许 \"{prefix}\"",
|
||||
alwaysAllow: '始终允许 "{prefix}"',
|
||||
deny: "拒绝",
|
||||
choiceTitle: "模型需要您选择",
|
||||
typeOwn: "输入自定义回答",
|
||||
@@ -507,5 +573,15 @@ export const zhCN = {
|
||||
reject: "拒绝",
|
||||
arguments: "参数",
|
||||
revisePlaceholder: "下一步之前需要更改什么?留空则直接继续。",
|
||||
pickerFilter: "过滤…",
|
||||
pickerEmpty: "暂无内容。",
|
||||
pickerLoadMore: "加载更多",
|
||||
pickerPick: "打开",
|
||||
pickerInstall: "安装",
|
||||
pickerUninstall: "卸载",
|
||||
pickerRename: "重命名…",
|
||||
pickerNew: "新建…",
|
||||
pickerNewPlaceholder: "名称(留空使用默认)",
|
||||
viewerClose: "关闭",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
export type BudgetState =
|
||||
| { kind: "off"; spent: number }
|
||||
| { kind: "running"; cap: number; spent: number; pct: number }
|
||||
| { kind: "warn"; cap: number; spent: number; pct: number }
|
||||
| { kind: "exhausted"; cap: number; spent: number; pct: number };
|
||||
|
||||
export function deriveBudgetState(
|
||||
cap: number | null | undefined,
|
||||
spent: number | null | undefined,
|
||||
): BudgetState {
|
||||
const safeSpent = typeof spent === "number" && spent >= 0 ? spent : 0;
|
||||
if (typeof cap !== "number" || cap <= 0) {
|
||||
return { kind: "off", spent: safeSpent };
|
||||
}
|
||||
const pct = (safeSpent / cap) * 100;
|
||||
if (pct >= 100) return { kind: "exhausted", cap, spent: safeSpent, pct };
|
||||
if (pct >= 80) return { kind: "warn", cap, spent: safeSpent, pct };
|
||||
return { kind: "running", cap, spent: safeSpent, pct };
|
||||
}
|
||||
|
||||
/** Default quick-cap menu — round dollar amounts users actually pick. */
|
||||
export const QUICK_CAPS_USD: ReadonlyArray<number> = [1, 5, 10, 25, 50];
|
||||
|
||||
/** 1.5× / 2× / 4× the current cap, snapped to a "nice" round number per bucket. */
|
||||
export function bumpSuggestions(currentCap: number): number[] {
|
||||
if (currentCap <= 0) return [];
|
||||
return [niceUp(currentCap * 1.5), niceUp(currentCap * 2), niceUp(currentCap * 4)];
|
||||
}
|
||||
|
||||
function niceUp(n: number): number {
|
||||
// Subtract a tiny epsilon before ceil so FP noise (0.4 * 1.5 = 0.6000…01)
|
||||
// doesn't bump a value to the next bucket.
|
||||
const eps = 1e-9;
|
||||
if (n < 1) return Math.ceil((n - eps) * 10) / 10;
|
||||
if (n < 10) return Math.ceil((n - eps) * 2) / 2;
|
||||
if (n < 100) return Math.ceil(n - eps);
|
||||
return Math.ceil((n - eps) / 5) * 5;
|
||||
}
|
||||
|
||||
/** Tone class shared between the cockpit tile and the settings gauge. */
|
||||
export function budgetTone(state: BudgetState): "" | "warn" | "err" {
|
||||
if (state.kind === "exhausted") return "err";
|
||||
if (state.kind === "warn") return "warn";
|
||||
return "";
|
||||
}
|
||||
@@ -4,6 +4,24 @@ export function fmtUsd(n: number | null | undefined): string {
|
||||
return `$${n.toFixed(n < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
/** Keep in sync with src/cli/ui/theme/tokens.ts USD_TO_CNY. */
|
||||
const USD_TO_CNY = 7.2;
|
||||
|
||||
/** USD-internal cost rendered in the wallet's display currency. Undefined currency → CNY (matches CLI default). */
|
||||
export function fmtCost(
|
||||
usd: number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
fractionDigits?: number,
|
||||
): string {
|
||||
if (usd === null || usd === undefined) return "—";
|
||||
const cur = currency ?? "CNY";
|
||||
const amount = cur === "CNY" ? usd * USD_TO_CNY : usd;
|
||||
if (amount === 0) return cur === "CNY" ? "¥0" : "$0";
|
||||
const sym = cur === "CNY" ? "¥" : cur === "USD" ? "$" : `${cur} `;
|
||||
const digits = fractionDigits ?? (Math.abs(amount) < 0.01 ? 6 : 4);
|
||||
return `${sym}${amount.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
export function fmtPct(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
|
||||
@@ -16,7 +16,6 @@ const TO_BACKEND = new Map(LANG_REGISTRY);
|
||||
const FROM_BACKEND = new Map(LANG_REGISTRY.map(([d, b]) => [b, d]));
|
||||
|
||||
const STORAGE_KEY = "rx.lang";
|
||||
const EXPLICIT_KEY = "rx.langExplicit";
|
||||
const listeners: Listener[] = [];
|
||||
let currentLang: DashboardLang = loadFromStorage() ?? "en";
|
||||
|
||||
@@ -30,22 +29,6 @@ function loadFromStorage(): DashboardLang | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExplicit(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(EXPLICIT_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markExplicit(): void {
|
||||
try {
|
||||
localStorage.setItem(EXPLICIT_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function toBackendLang(lang: DashboardLang): string {
|
||||
return TO_BACKEND.get(lang) ?? lang;
|
||||
}
|
||||
@@ -54,23 +37,12 @@ function fromBackendLang(raw: string): DashboardLang {
|
||||
return (FROM_BACKEND.get(raw) as DashboardLang) ?? "en";
|
||||
}
|
||||
|
||||
/** Fetch lang from backend on startup. */
|
||||
/** Adopt server lang on startup; localStorage is render-cache only, never pushed back. */
|
||||
export async function initLangFromServer(): Promise<void> {
|
||||
try {
|
||||
const stored = loadFromStorage();
|
||||
const res = await api<{ lang?: string }>("/settings");
|
||||
const serverLang = res.lang ? fromBackendLang(res.lang) : null;
|
||||
|
||||
if (!serverLang || stored === serverLang) return;
|
||||
|
||||
if (isExplicit() && stored) {
|
||||
// User explicitly chose a language — push to server.
|
||||
api("/settings", { method: "POST", body: { lang: toBackendLang(stored) } })
|
||||
.catch((err) => console.error("[reasonix dashboard] lang sync:", err));
|
||||
return;
|
||||
}
|
||||
|
||||
// No explicit choice yet (first visit or cleared storage) — adopt server value.
|
||||
if (!serverLang || serverLang === currentLang) return;
|
||||
currentLang = serverLang;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, serverLang);
|
||||
@@ -79,7 +51,7 @@ export async function initLangFromServer(): Promise<void> {
|
||||
}
|
||||
for (const cb of listeners) cb();
|
||||
} catch {
|
||||
/* offline — keep localStorage value */
|
||||
/* offline — keep last-known value rendering */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +62,6 @@ export function getLang(): DashboardLang {
|
||||
export function setLang(lang: DashboardLang): void {
|
||||
if (!SUPPORTED.has(lang)) return;
|
||||
currentLang = lang;
|
||||
markExplicit();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export type IntervalUnit = "s" | "m" | "h";
|
||||
|
||||
export interface LoopRunStatus {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
/** Wall-clock ms until the next fire — server reports a remaining duration, not an absolute. */
|
||||
nextFireMs: number;
|
||||
}
|
||||
|
||||
/** Quick-pick intervals in ms — covers the 95% of cases users actually run. */
|
||||
export const INTERVAL_PRESETS_MS: ReadonlyArray<{ ms: number; label: string }> = [
|
||||
{ ms: 30_000, label: "30s" },
|
||||
{ ms: 60_000, label: "1m" },
|
||||
{ ms: 5 * 60_000, label: "5m" },
|
||||
{ ms: 15 * 60_000, label: "15m" },
|
||||
{ ms: 60 * 60_000, label: "1h" },
|
||||
{ ms: 6 * 60 * 60_000, label: "6h" },
|
||||
];
|
||||
|
||||
const UNIT_TO_MS: Record<IntervalUnit, number> = {
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 60 * 60_000,
|
||||
};
|
||||
|
||||
const MIN_INTERVAL_MS = 5_000;
|
||||
const MAX_INTERVAL_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** Convert a "30" + "s" pair to ms, returning null if out of [5s, 6h]. */
|
||||
export function parseCustomInterval(value: string, unit: IntervalUnit): number | null {
|
||||
const n = Number.parseFloat(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const ms = Math.round(n * UNIT_TO_MS[unit]);
|
||||
if (ms < MIN_INTERVAL_MS || ms > MAX_INTERVAL_MS) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
/** Human-friendly "5m 12s" / "12s" / "2h 45m" — shows two largest non-zero units. */
|
||||
export function formatRemaining(ms: number): string {
|
||||
const safe = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(safe / 3600);
|
||||
const m = Math.floor((safe % 3600) / 60);
|
||||
const s = safe % 60;
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Pre-release with same core sorts BELOW the bare version — matches npm `latest` dist-tag semantics. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const [aCore = "0", aPre = ""] = a.split("-", 2);
|
||||
const [bCore = "0", bPre = ""] = b.split("-", 2);
|
||||
const aParts = aCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
|
||||
const bParts = bCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
if (!aPre && !bPre) return 0;
|
||||
if (!aPre) return 1;
|
||||
if (!bPre) return -1;
|
||||
return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
|
||||
}
|
||||
@@ -6,15 +6,17 @@ import {
|
||||
ChoiceModal,
|
||||
EditReviewModal,
|
||||
type OnResolve,
|
||||
PickerModal,
|
||||
PlanModal,
|
||||
RevisionModal,
|
||||
ShellModal,
|
||||
ViewerModal,
|
||||
WorkspaceModal,
|
||||
parseToolArgs,
|
||||
} from "../components/chat-internals.js";
|
||||
import { MODE, TOKEN, api } from "../lib/api.js";
|
||||
import { appBus, showToast } from "../lib/bus.js";
|
||||
import { fmtUsd } from "../lib/format.js";
|
||||
import { fmtCost, fmtUsd } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
@@ -183,8 +185,58 @@ export function ChatPanel() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// rAF-coalesce assistant_delta events. A streaming turn fires ~20
|
||||
// deltas/sec — committing each to React state forces a parent
|
||||
// re-render per delta, which used to thrash the chat feed. Now the
|
||||
// accumulated text lives in a ref and we flush at most once per
|
||||
// frame, capping the streaming-bubble re-render rate at the display
|
||||
// refresh rate. assistant_final cancels the pending flush.
|
||||
const streamBufRef = useRef<StreamingState | null>(null);
|
||||
const streamRafRef = useRef<number | null>(null);
|
||||
const flushStreaming = useCallback(() => {
|
||||
streamRafRef.current = null;
|
||||
if (streamBufRef.current) setStreaming(streamBufRef.current);
|
||||
}, []);
|
||||
const cancelStreamingRaf = useCallback(() => {
|
||||
if (streamRafRef.current !== null) {
|
||||
cancelAnimationFrame(streamRafRef.current);
|
||||
streamRafRef.current = null;
|
||||
}
|
||||
streamBufRef.current = null;
|
||||
}, []);
|
||||
|
||||
// SSE reconnect drops missed deltas / finals / modals — server only
|
||||
// snapshots `busy-change` on (re)connect. Pull /messages + /modal to
|
||||
// recover canonical state, otherwise UI wedges on the last seen state (#521).
|
||||
const refetchCanonicalState = useCallback(async () => {
|
||||
try {
|
||||
const data = await api<MessagesResponse>("/messages");
|
||||
setMessages(data.messages ?? []);
|
||||
setBusy(Boolean(data.busy));
|
||||
cancelStreamingRaf();
|
||||
setStreaming(null);
|
||||
setActiveTool(null);
|
||||
} catch {
|
||||
/* keep current state — next event or next reconnect will retry */
|
||||
}
|
||||
try {
|
||||
const m = await api<ModalEnvelope>("/modal");
|
||||
setModal(m.modal ?? null);
|
||||
} catch {
|
||||
/* modal endpoint optional in standalone */
|
||||
}
|
||||
}, [cancelStreamingRaf]);
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(`/api/events?token=${TOKEN}`);
|
||||
let firstOpen = true;
|
||||
es.onopen = () => {
|
||||
if (firstOpen) {
|
||||
firstOpen = false;
|
||||
return;
|
||||
}
|
||||
void refetchCanonicalState();
|
||||
};
|
||||
es.onmessage = (ev) => {
|
||||
let dash;
|
||||
try {
|
||||
@@ -202,14 +254,20 @@ export function ChatPanel() {
|
||||
return;
|
||||
}
|
||||
if (dash.kind === "assistant_delta") {
|
||||
setStreaming((cur) => {
|
||||
const text = (cur?.text ?? "") + (dash.contentDelta ?? "");
|
||||
const reasoning = (cur?.reasoning ?? "") + (dash.reasoningDelta ?? "");
|
||||
return { id: dash.id, text, reasoning };
|
||||
});
|
||||
const cur = streamBufRef.current;
|
||||
const baseId = cur?.id === dash.id ? cur : null;
|
||||
streamBufRef.current = {
|
||||
id: dash.id,
|
||||
text: (baseId?.text ?? "") + (dash.contentDelta ?? ""),
|
||||
reasoning: (baseId?.reasoning ?? "") + (dash.reasoningDelta ?? ""),
|
||||
};
|
||||
if (streamRafRef.current === null) {
|
||||
streamRafRef.current = requestAnimationFrame(flushStreaming);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dash.kind === "assistant_final") {
|
||||
cancelStreamingRaf();
|
||||
setStreaming(null);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
@@ -264,12 +322,16 @@ export function ChatPanel() {
|
||||
};
|
||||
es.onerror = () => {
|
||||
// Auto-reconnect by default; surface a brief banner on persistent
|
||||
// failure but don't tear down — EventSource retries in the background.
|
||||
// failure but don't tear down — EventSource retries in the
|
||||
// background. The next `onopen` will resync canonical state.
|
||||
setError(t("chat.eventStreamError"));
|
||||
setTimeout(() => setError(null), 3000);
|
||||
};
|
||||
return () => es.close();
|
||||
}, []);
|
||||
return () => {
|
||||
es.close();
|
||||
cancelStreamingRaf();
|
||||
};
|
||||
}, [refetchCanonicalState, cancelStreamingRaf]);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
@@ -559,7 +621,7 @@ export function ChatPanel() {
|
||||
<div class="chat-shell">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">
|
||||
<div class="chips" style="padding:0">
|
||||
<span class="chip-f active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
|
||||
<span class="chip-f static active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
|
||||
</div>
|
||||
<div class="header-pickers" style="margin-left:auto">
|
||||
${
|
||||
@@ -684,7 +746,11 @@ export function ChatPanel() {
|
||||
? html`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "revision"
|
||||
? html`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: null
|
||||
: modal.kind === "picker"
|
||||
? html`<${PickerModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "viewer"
|
||||
? html`<${ViewerModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: null
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -701,7 +767,7 @@ export function ChatPanel() {
|
||||
<${ChatMessage}
|
||||
key=${m.id}
|
||||
msg=${m}
|
||||
streaming=${streaming && streaming.id === m.id}
|
||||
streaming=${Boolean(streaming && streaming.id === m.id)}
|
||||
/>
|
||||
`,
|
||||
)
|
||||
@@ -786,11 +852,12 @@ interface SideRailProps {
|
||||
function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
useLang();
|
||||
if (!stats && !activePlan) return html`<aside class="chat-rail"></aside>`;
|
||||
const cachePct = stats ? Math.round(stats.cacheHitRatio * 100) : 0;
|
||||
const cachePct = stats ? stats.cacheHitRatio * 100 : 0;
|
||||
const cacheTone = cachePct >= 80 ? "ok" : cachePct >= 50 ? "" : "warn";
|
||||
const showBudget = stats != null && typeof budgetUsd === "number" && budgetUsd > 0;
|
||||
const budgetPct = showBudget ? Math.min(120, (stats.totalCostUsd / budgetUsd) * 100) : 0;
|
||||
const budgetTone = budgetPct >= 100 ? "err" : budgetPct >= 80 ? "warn" : "";
|
||||
const walletCurrency = stats?.balance?.[0]?.currency;
|
||||
return html`
|
||||
<aside class="chat-rail">
|
||||
${activePlan ? html`<${ActivePlanCard} plan=${activePlan} />` : null}
|
||||
@@ -801,11 +868,11 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
<div class="rh">${t("chat.railSession")}</div>
|
||||
<div class="rail-kv"><span class="k">${t("chat.railTurns")}</span><span class="v">${stats.turns.toLocaleString()}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("chat.railPromptTok")}</span><span class="v">${stats.lastPromptTokens.toLocaleString()}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtUsd(stats.totalCostUsd)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtCost(stats.totalCostUsd, walletCurrency)}</span></div>
|
||||
<div class="progress-row" style="margin-top:8px">
|
||||
<span class="lbl">${t("chat.railCacheHit")}</span>
|
||||
<div class=${`progress ${cacheTone}`}><div class="progress-fill" style=${`width:${cachePct}%`}></div></div>
|
||||
<span class="v">${cachePct}%</span>
|
||||
<span class="v">${cachePct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -819,7 +886,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
<div class="progress-row">
|
||||
<span class="lbl">${t("chat.railSpend")}</span>
|
||||
<div class=${`progress ${budgetTone}`}><div class="progress-fill" style=${`width:${Math.min(100, budgetPct)}%`}></div></div>
|
||||
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtUsd(stats.totalCostUsd)} / ${fmtUsd(budgetUsd)}</span>
|
||||
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtCost(stats.totalCostUsd, walletCurrency)} / ${fmtCost(budgetUsd, walletCurrency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -989,11 +1056,11 @@ function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
|
||||
</span>
|
||||
<span class="status-item">
|
||||
<span class="status-label">${t("chat.statusTurn")}</span>
|
||||
<code>${fmtUsd(stats.lastTurnCostUsd)}</code>
|
||||
<code>${fmtCost(stats.lastTurnCostUsd, balance?.currency)}</code>
|
||||
</span>
|
||||
<span class="status-item">
|
||||
<span class="status-label">${t("chat.statusSession")}</span>
|
||||
<code>${fmtUsd(stats.totalCostUsd)}</code>
|
||||
<code>${fmtCost(stats.totalCostUsd, balance?.currency)}</code>
|
||||
<span class="muted" style="font-size: 10px;">
|
||||
${t("chat.statusTurns", { count: stats.turns, s: stats.turns === 1 ? "" : "s" })}
|
||||
</span>
|
||||
|
||||
@@ -68,6 +68,7 @@ export function HooksPanel() {
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [eventFilter, setEventFilter] = useState<string>("all");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -129,19 +130,31 @@ export function HooksPanel() {
|
||||
data.events.length > 0
|
||||
? data.events
|
||||
: Array.from(new Set(matrixRows.flatMap((r) => Object.keys(r.cells))));
|
||||
const visibleRows =
|
||||
eventFilter === "all"
|
||||
? matrixRows
|
||||
: matrixRows.filter((r) => r.cells[eventFilter]?.on);
|
||||
const gridCols = `220px repeat(${Math.max(events.length, 1)}, minmax(0, 1fr))`;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
|
||||
${data.events.map((ev) => html`<span class="chip-f">${ev}</span>`)}
|
||||
<span
|
||||
class=${`chip-f ${eventFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setEventFilter("all")}
|
||||
>${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
|
||||
${data.events.map(
|
||||
(ev) => html`<span
|
||||
class=${`chip-f ${eventFilter === ev ? "active" : ""}`}
|
||||
onClick=${() => setEventFilter(ev)}
|
||||
>${ev}</span>`,
|
||||
)}
|
||||
</div>
|
||||
${info ? html`<div><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err">${error}</div>` : null}
|
||||
|
||||
${sectionH3(t("hooks.eventMatrix"), t("hooks.matrixSub", { scripts: matrixRows.length, s: matrixRows.length === 1 ? "" : "s", events: events.length }))}${
|
||||
matrixRows.length === 0
|
||||
visibleRows.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">
|
||||
${t("hooks.noHooks")}
|
||||
</div>`
|
||||
@@ -152,7 +165,7 @@ export function HooksPanel() {
|
||||
<div>${t("hooks.colScript")}</div>
|
||||
${events.map((ev) => html`<div>${ev}</div>`)}
|
||||
</div>
|
||||
${matrixRows.map(
|
||||
${visibleRows.map(
|
||||
(r) => html`
|
||||
<div class="row" style=${`grid-template-columns:${gridCols}`}>
|
||||
<div class="cell" title=${r.command}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fmtCompactNum, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
|
||||
import { budgetTone, deriveBudgetState } from "../lib/budget.js";
|
||||
import { fmtCompactNum, fmtCost, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { compareVersions } from "../lib/version.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface CockpitKpi {
|
||||
@@ -65,6 +67,9 @@ interface OverviewData {
|
||||
toolCount?: number;
|
||||
cwd?: string;
|
||||
cockpit?: CockpitData;
|
||||
budgetUsd?: number | null;
|
||||
/** Cumulative session spend in USD — set when a session is attached. */
|
||||
sessionSpendUsd?: number | null;
|
||||
}
|
||||
|
||||
function kpi(label: string, value: unknown, delta?: unknown, deltaTone?: "up" | "down" | "flat") {
|
||||
@@ -107,6 +112,25 @@ function balanceKpi(c: CockpitData) {
|
||||
return kpi(t("overview.balance"), `${symbol}${c.balance.total}`, c.balance.currency, "flat");
|
||||
}
|
||||
|
||||
function budgetKpi(o: OverviewData) {
|
||||
const state = deriveBudgetState(o.budgetUsd, o.cockpit?.currentSession?.totalCostUsd ?? null);
|
||||
if (state.kind === "off") return null;
|
||||
const tone = budgetTone(state);
|
||||
const valueColor =
|
||||
tone === "err"
|
||||
? "color:var(--c-err)"
|
||||
: tone === "warn"
|
||||
? "color:var(--c-warn)"
|
||||
: "";
|
||||
return html`
|
||||
<div class="kpi cock-w-1">
|
||||
<div class="label">${t("overview.budget")}</div>
|
||||
<div class="value" style=${valueColor}>${fmtUsd(state.spent)} / ${fmtUsd(state.cap)}</div>
|
||||
<div class=${`progress ${tone}`} style="margin-top:4px"><div class="progress-fill" style=${`width:${Math.min(100, state.pct)}%`}></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function tokens7dKpi(c: CockpitData) {
|
||||
if (!c.tokens7d) return kpi(t("overview.tokens7d"), "—", t("overview.noUsageYet"), "flat");
|
||||
const d = deltaPctText(c.tokens7d.deltaPct);
|
||||
@@ -144,6 +168,7 @@ function currentSessionBlock(c: CockpitData) {
|
||||
`;
|
||||
}
|
||||
const s = c.currentSession;
|
||||
const currency = c.balance?.currency;
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">${t("overview.currentSession")}</span></div>
|
||||
@@ -153,7 +178,7 @@ function currentSessionBlock(c: CockpitData) {
|
||||
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:8px;font-family:var(--font-mono);font-size:11px">
|
||||
<div><span style="color:var(--fg-3)">${t("overview.promptTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.lastPromptTokens)}</div></div>
|
||||
<div><span style="color:var(--fg-3)">${t("overview.completionTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.completionTokens)}</div></div>
|
||||
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtUsd(s.totalCostUsd)}</div></div>
|
||||
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtCost(s.totalCostUsd, currency)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -185,7 +210,7 @@ function costTrendSpark(c: CockpitData) {
|
||||
return html`
|
||||
<div class="chart cock-w-2">
|
||||
<div class="chart-h"><span class="title">${t("overview.costTrend")}</span></div>
|
||||
<div class="chart-v">${fmtUsd(avg)}<span class="unit">${t("overview.dayAvg")}</span></div>
|
||||
<div class="chart-v">${fmtCost(avg, c.balance?.currency)}<span class="unit">${t("overview.dayAvg")}</span></div>
|
||||
<div class="chart-spark">
|
||||
<svg viewBox=${`0 0 ${w} ${h}`} preserveAspectRatio="none">
|
||||
<polyline fill="none" stroke="var(--c-brand)" stroke-width="1.5" points=${points} />
|
||||
@@ -256,7 +281,8 @@ export function OverviewPanel() {
|
||||
recentPlans: null,
|
||||
toolActivity: null,
|
||||
};
|
||||
const upToDate = o.latestVersion ? o.latestVersion === o.version : null;
|
||||
const upToDate =
|
||||
o.latestVersion && o.version ? compareVersions(o.version, o.latestVersion) >= 0 : null;
|
||||
const versionDelta =
|
||||
upToDate === null ? t("overview.checking") : upToDate ? t("overview.latest") : `latest: ${o.latestVersion}`;
|
||||
const versionTone: "up" | "down" | "flat" = upToDate === false ? "down" : "flat";
|
||||
@@ -284,6 +310,7 @@ export function OverviewPanel() {
|
||||
${tokens7dKpi(c)}
|
||||
${cacheHitKpi(c)}
|
||||
${toolCallsKpi(c)}
|
||||
${budgetKpi(o)}
|
||||
|
||||
${currentSessionBlock(c)}
|
||||
${costTrendSpark(c)}
|
||||
|
||||
@@ -122,8 +122,8 @@ export function PermissionsPanel() {
|
||||
}
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
|
||||
<span class="chip-f">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
|
||||
<span class="chip-f static active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
|
||||
<span class="chip-f static">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
@@ -29,7 +29,7 @@ interface PlansData {
|
||||
function statusPill(p: ArchivedPlan) {
|
||||
if (p.completionRatio >= 1) return html`<span class="pill ok">${t("plans.done")}</span>`;
|
||||
if (p.completionRatio > 0) return html`<span class="pill info">${t("plans.active")}</span>`;
|
||||
return html`<span class="pill">idle</span>`;
|
||||
return html`<span class="pill">${t("plans.idle")}</span>`;
|
||||
}
|
||||
|
||||
export function PlansPanel() {
|
||||
@@ -37,6 +37,7 @@ export function PlansPanel() {
|
||||
const { data, error, loading } = usePoll<PlansData>("/plans", 8000);
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "done">("all");
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("plans.loading")}</div>`;
|
||||
@@ -48,13 +49,19 @@ export function PlansPanel() {
|
||||
${t("plans.noPlans")}
|
||||
</div>`;
|
||||
|
||||
const statusFiltered =
|
||||
statusFilter === "all"
|
||||
? plans
|
||||
: statusFilter === "active"
|
||||
? plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1)
|
||||
: plans.filter((p) => p.completionRatio >= 1);
|
||||
const filtered = filter.trim()
|
||||
? plans.filter(
|
||||
? statusFiltered.filter(
|
||||
(p) =>
|
||||
p.session.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(p.summary ?? "").toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: plans;
|
||||
: statusFiltered;
|
||||
|
||||
const open = openIdx !== null ? plans[openIdx] : null;
|
||||
|
||||
@@ -71,12 +78,21 @@ export function PlansPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${plans.length}</span></span>
|
||||
<span class="chip-f">
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("all")}
|
||||
>${t("common.all")} <span class="ct">${plans.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "active" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("active")}
|
||||
>
|
||||
${t("plans.active")}
|
||||
<span class="ct">${plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1).length}</span>
|
||||
</span>
|
||||
<span class="chip-f">
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "done" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("done")}
|
||||
>
|
||||
${t("plans.done")} <span class="ct">${plans.filter((p) => p.completionRatio >= 1).length}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+443
-124
@@ -1,13 +1,50 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
import { api } from "../lib/api.js";
|
||||
import { fmtBytes, fmtNum, fmtRelativeTime } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SemanticConfigView {
|
||||
provider: "ollama" | "openai-compat";
|
||||
ollama: {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
};
|
||||
openaiCompat: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
apiKeySet: boolean;
|
||||
model: string;
|
||||
extraBody: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SemanticData {
|
||||
attached?: boolean;
|
||||
reason?: string;
|
||||
root?: string;
|
||||
provider?: "ollama" | "openai-compat";
|
||||
providerConfig?: SemanticConfigView;
|
||||
providerStatus?:
|
||||
| {
|
||||
kind: "ollama";
|
||||
ready: boolean;
|
||||
baseUrl: string;
|
||||
binaryFound?: boolean;
|
||||
daemonRunning?: boolean;
|
||||
modelPulled?: boolean;
|
||||
modelName?: string;
|
||||
installedModels?: string[];
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
kind: "openai-compat";
|
||||
ready: boolean;
|
||||
baseUrl: string;
|
||||
apiKeySet: boolean;
|
||||
model: string;
|
||||
extraBodyKeys: string[];
|
||||
};
|
||||
index?: IndexInfo;
|
||||
job?: SemanticJob | null;
|
||||
pull?: { status: string; startedAt: number; lastLine?: string } | null;
|
||||
@@ -23,17 +60,25 @@ interface SemanticData {
|
||||
|
||||
interface IndexInfo {
|
||||
exists: boolean;
|
||||
provider?: "ollama" | "openai-compat";
|
||||
chunks?: number;
|
||||
files?: number;
|
||||
dim?: number;
|
||||
sizeBytes?: number;
|
||||
lastBuiltMs?: number;
|
||||
model?: string;
|
||||
builtWith?: { provider: "ollama" | "openai-compat"; model: string };
|
||||
current?: { provider: "ollama" | "openai-compat"; model: string };
|
||||
compatible?: boolean;
|
||||
mismatch?: "provider" | "model" | null;
|
||||
}
|
||||
|
||||
interface SemanticJob {
|
||||
phase: string;
|
||||
startedAt: number;
|
||||
finishedAt?: number | null;
|
||||
cancelledAt?: number | null;
|
||||
lastPhase?: string | null;
|
||||
chunksTotal?: number;
|
||||
chunksDone?: number;
|
||||
filesScanned?: number;
|
||||
@@ -50,17 +95,44 @@ interface SemanticJob {
|
||||
};
|
||||
}
|
||||
|
||||
interface SemanticConfigDraft {
|
||||
provider: "ollama" | "openai-compat";
|
||||
ollama: {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
};
|
||||
openaiCompat: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
extraBodyText: string;
|
||||
apiKeySet: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SemanticDraftValidation {
|
||||
extraBody: Record<string, unknown>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function SemanticPanel() {
|
||||
useLang();
|
||||
const [data, setData] = useState<SemanticData | null>(null);
|
||||
const [draft, setDraft] = useState<SemanticConfigDraft | null>(null);
|
||||
const [draftDirty, setDraftDirty] = useState(false);
|
||||
const draftDirtyRef = useRef(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api<SemanticData>("/semantic");
|
||||
setData(r);
|
||||
const [semantic, config] = await Promise.all([
|
||||
api<SemanticData>("/semantic"),
|
||||
api<SemanticConfigView>("/semantic/config"),
|
||||
]);
|
||||
setData(semantic);
|
||||
setDraft((current) => (current && draftDirtyRef.current ? current : toConfigDraft(config)));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
@@ -69,7 +141,7 @@ export function SemanticPanel() {
|
||||
useEffect(() => {
|
||||
load();
|
||||
const phase = data?.job?.phase;
|
||||
const running = phase === "scan" || phase === "embed" || phase === "write";
|
||||
const running = isActiveSemanticPhase(phase);
|
||||
const pulling = data?.pull?.status === "pulling";
|
||||
const ms = running || pulling ? 1200 : 5000;
|
||||
const id = setInterval(load, ms);
|
||||
@@ -78,10 +150,18 @@ export function SemanticPanel() {
|
||||
|
||||
const start = useCallback(
|
||||
async (rebuild: boolean) => {
|
||||
if (!draft) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const validation = validateSemanticDraft(draft);
|
||||
if (draftDirty) {
|
||||
throw new Error(t("semantic.saveBeforeIndex"));
|
||||
}
|
||||
if (validation.error) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
await api("/semantic/start", { method: "POST", body: { rebuild: !!rebuild } });
|
||||
setInfo(rebuild ? t("semantic.rebuildStarted") : t("semantic.incrementalStarted"));
|
||||
await load();
|
||||
@@ -91,7 +171,7 @@ export function SemanticPanel() {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[load],
|
||||
[draft, draftDirty, load],
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
@@ -113,10 +193,11 @@ export function SemanticPanel() {
|
||||
setError(null);
|
||||
setInfo(t("semantic.startingDaemon"));
|
||||
try {
|
||||
const r = await api<{ ready: boolean }>("/semantic/ollama/start", { method: "POST", body: {} });
|
||||
setInfo(
|
||||
r.ready ? t("semantic.daemonUp") : t("semantic.daemonTimeout"),
|
||||
);
|
||||
const r = await api<{ ready: boolean }>("/semantic/ollama/start", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
setInfo(r.ready ? t("semantic.daemonUp") : t("semantic.daemonTimeout"));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -142,10 +223,45 @@ export function SemanticPanel() {
|
||||
[load],
|
||||
);
|
||||
|
||||
if (!data && !error)
|
||||
const saveProviderConfig = useCallback(async () => {
|
||||
if (!draft) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const extraBody = semanticValidation.extraBody;
|
||||
await api("/semantic/config", {
|
||||
method: "POST",
|
||||
body: {
|
||||
provider: draft.provider,
|
||||
ollama: {
|
||||
baseUrl: draft.ollama.baseUrl,
|
||||
model: draft.ollama.model,
|
||||
},
|
||||
openaiCompat: {
|
||||
baseUrl: draft.openaiCompat.baseUrl,
|
||||
apiKey: draft.openaiCompat.apiKey,
|
||||
model: draft.openaiCompat.model,
|
||||
extraBody,
|
||||
},
|
||||
},
|
||||
});
|
||||
setDraftDirty(false);
|
||||
draftDirtyRef.current = false;
|
||||
setInfo(t("semantic.savedConfig", { count: 1 }));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [draft, load]);
|
||||
|
||||
if (!data && !error) {
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("common.loading")}</div>`;
|
||||
}
|
||||
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
|
||||
if (!data) return null;
|
||||
if (!data || !draft) return null;
|
||||
|
||||
if (!data.attached) {
|
||||
return html`
|
||||
@@ -158,43 +274,185 @@ export function SemanticPanel() {
|
||||
|
||||
const job = data.job;
|
||||
const phase = job?.phase;
|
||||
const running = phase === "scan" || phase === "embed" || phase === "write";
|
||||
const running = isActiveSemanticPhase(phase);
|
||||
const pull = data.pull;
|
||||
const pulling = pull?.status === "pulling";
|
||||
|
||||
const o = data.ollama ?? {};
|
||||
const binaryFound = o.binaryFound === true;
|
||||
const daemonRunning = o.daemonRunning === true;
|
||||
const modelPulled = o.modelPulled === true;
|
||||
const modelName = o.modelName ?? "nomic-embed-text";
|
||||
const installedModels = o.installedModels ?? [];
|
||||
const ready = binaryFound && daemonRunning && modelPulled;
|
||||
const provider = data.providerStatus?.kind ?? draft.provider;
|
||||
const ready = data.providerStatus?.ready === true;
|
||||
const isOllama = provider === "ollama";
|
||||
const ollama = data.providerStatus?.kind === "ollama" ? data.providerStatus : null;
|
||||
const remote = data.providerStatus?.kind === "openai-compat" ? data.providerStatus : null;
|
||||
const binaryFound = ollama?.binaryFound === true;
|
||||
const daemonRunning = ollama?.daemonRunning === true;
|
||||
const modelPulled = ollama?.modelPulled === true;
|
||||
const modelName = isOllama
|
||||
? (ollama?.modelName ?? draft.ollama.model ?? "nomic-embed-text")
|
||||
: draft.openaiCompat.model;
|
||||
|
||||
const sectionH3 = (text: string) => html`
|
||||
<h3 style="margin:18px 0 8px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">${text}</h3>
|
||||
`;
|
||||
|
||||
const idx = data.index;
|
||||
const indexReady = idx?.exists === true && idx.compatible !== false;
|
||||
const indexMismatch = idx?.exists === true && idx.compatible === false;
|
||||
const semanticValidation = validateSemanticDraft(draft);
|
||||
const semanticDraftBlocked = draftDirty || semanticValidation.error !== null;
|
||||
return html`
|
||||
<div style="display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:14px;align-items:start">
|
||||
<div style="display:flex;flex-direction:column;gap:10px;min-width:0">
|
||||
<div class="chips">
|
||||
<span class=${`chip-f ${idx?.exists ? "active" : ""}`}>
|
||||
${idx?.exists ? t("semantic.indexBuilt") : t("semantic.noIndex")}
|
||||
<span class=${`chip-f static ${indexReady ? "active" : ""}`}>
|
||||
${indexReady ? t("semantic.indexBuilt") : t("semantic.noIndex")}
|
||||
</span>
|
||||
${
|
||||
ready
|
||||
? html`<span class="chip-f" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
|
||||
: html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
|
||||
? html`<span class="chip-f static" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
|
||||
: html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
|
||||
}
|
||||
</div>
|
||||
${info ? html`<div><span class="pill info">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err">${error}</div>` : null}
|
||||
|
||||
${idx?.exists ? html`<${SemanticSearchSection} />` : null}
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">${t("semantic.provider")}</span></div>
|
||||
<div class="form-row">
|
||||
<span class="lbl">${t("semantic.providerType")}</span>
|
||||
<select
|
||||
class="input mono"
|
||||
value=${draft.provider}
|
||||
onInput=${(e: Event) => {
|
||||
draftDirtyRef.current = true;
|
||||
setDraftDirty(true);
|
||||
setDraft({
|
||||
...draft,
|
||||
provider: (e.target as HTMLSelectElement).value as "ollama" | "openai-compat",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="openai-compat">OpenAI-Compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
${
|
||||
draft.provider === "ollama"
|
||||
? html`
|
||||
<div class="form-row">
|
||||
<span class="lbl">${t("semantic.model")}</span>
|
||||
<input
|
||||
class="input mono"
|
||||
type="text"
|
||||
value=${draft.ollama.model}
|
||||
onInput=${(e: Event) => {
|
||||
draftDirtyRef.current = true;
|
||||
setDraftDirty(true);
|
||||
setDraft({
|
||||
...draft,
|
||||
ollama: { ...draft.ollama, model: (e.target as HTMLInputElement).value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="form-row">
|
||||
<span class="lbl">${t("semantic.apiUrl")}</span>
|
||||
<input
|
||||
class="input mono"
|
||||
type="text"
|
||||
placeholder="https://api.openai.com/v1/embeddings"
|
||||
value=${draft.openaiCompat.baseUrl}
|
||||
onInput=${(e: Event) => {
|
||||
draftDirtyRef.current = true;
|
||||
setDraftDirty(true);
|
||||
setDraft({
|
||||
...draft,
|
||||
openaiCompat: {
|
||||
...draft.openaiCompat,
|
||||
baseUrl: (e.target as HTMLInputElement).value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<span class="lbl">${t("semantic.apiKey")}</span>
|
||||
<input
|
||||
class="input mono"
|
||||
type="password"
|
||||
placeholder=${draft.openaiCompat.apiKeySet ? t("semantic.keepExistingKey") : "sk-..."}
|
||||
value=${draft.openaiCompat.apiKey}
|
||||
onInput=${(e: Event) => {
|
||||
draftDirtyRef.current = true;
|
||||
setDraftDirty(true);
|
||||
setDraft({
|
||||
...draft,
|
||||
openaiCompat: {
|
||||
...draft.openaiCompat,
|
||||
apiKey: (e.target as HTMLInputElement).value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div style="color:var(--fg-3);font-size:12px">${t("semantic.apiKeyStoredNote")}</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<span class="lbl">${t("semantic.model")}</span>
|
||||
<input
|
||||
class="input mono"
|
||||
type="text"
|
||||
value=${draft.openaiCompat.model}
|
||||
onInput=${(e: Event) => {
|
||||
draftDirtyRef.current = true;
|
||||
setDraftDirty(true);
|
||||
setDraft({
|
||||
...draft,
|
||||
openaiCompat: {
|
||||
...draft.openaiCompat,
|
||||
model: (e.target as HTMLInputElement).value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<details style="margin-top:10px">
|
||||
<summary style="cursor:pointer;color:var(--fg-2);font-size:12px">${t("semantic.customRequestBody")}</summary>
|
||||
<div class="form-row" style="margin-top:10px">
|
||||
<span class="lbl">${t("semantic.customRequestBody")}</span>
|
||||
<textarea
|
||||
class="input mono"
|
||||
rows="6"
|
||||
value=${draft.openaiCompat.extraBodyText}
|
||||
onInput=${(e: Event) => {
|
||||
draftDirtyRef.current = true;
|
||||
setDraftDirty(true);
|
||||
setDraft({
|
||||
...draft,
|
||||
openaiCompat: {
|
||||
...draft.openaiCompat,
|
||||
extraBodyText: (e.target as HTMLTextAreaElement).value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
></textarea>
|
||||
</div>
|
||||
</details>
|
||||
${
|
||||
semanticValidation.error
|
||||
? html`<div style="color:var(--c-err);font-size:12px;margin-top:-2px">${semanticValidation.error}</div>`
|
||||
: null
|
||||
}
|
||||
`
|
||||
}
|
||||
<div style="display:flex;gap:6px;margin-top:10px">
|
||||
<button class="btn primary" disabled=${busy || semanticValidation.error !== null} onClick=${saveProviderConfig}>${t("common.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
${info ? html`<div><span class="pill info">${info}</span></div>` : null}
|
||||
|
||||
${indexReady ? html`<${SemanticSearchSection} />` : null}
|
||||
|
||||
${
|
||||
!binaryFound
|
||||
isOllama && !binaryFound
|
||||
? html`
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">${t("semantic.installOllama")}</span></div>
|
||||
@@ -211,7 +469,7 @@ export function SemanticPanel() {
|
||||
: null
|
||||
}
|
||||
${
|
||||
binaryFound && !daemonRunning
|
||||
isOllama && binaryFound && !daemonRunning
|
||||
? html`
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">${t("semantic.daemon")}</span></div>
|
||||
@@ -227,7 +485,7 @@ export function SemanticPanel() {
|
||||
: null
|
||||
}
|
||||
${
|
||||
daemonRunning && !modelPulled
|
||||
isOllama && daemonRunning && !modelPulled
|
||||
? html`
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">${t("semantic.model")}</span></div>
|
||||
@@ -254,6 +512,18 @@ export function SemanticPanel() {
|
||||
`
|
||||
: null
|
||||
}
|
||||
${
|
||||
!isOllama
|
||||
? html`
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">${t("semantic.remoteProvider")}</span></div>
|
||||
<div class="card-b" style="font-size:13px;color:var(--fg-2)">
|
||||
${t("semantic.remoteProviderDesc")}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
|
||||
${
|
||||
job
|
||||
@@ -272,7 +542,9 @@ export function SemanticPanel() {
|
||||
<span class="meta">
|
||||
${
|
||||
idx?.exists
|
||||
? html`<span class="pill ok">${t("semantic.builtStatus")}</span>`
|
||||
? idx.compatible === false
|
||||
? html`<span class="pill warn">${t("semantic.incompatibleStatus")}</span>`
|
||||
: html`<span class="pill ok">${t("semantic.builtStatus")}</span>`
|
||||
: html`<span class="pill">${t("system.none")}</span>`
|
||||
}
|
||||
</span>
|
||||
@@ -280,44 +552,56 @@ export function SemanticPanel() {
|
||||
${
|
||||
idx?.exists
|
||||
? html`
|
||||
<div class="rail-kv"><span class="k">${t("semantic.provider")}</span><span class="v">${idx.builtWith?.provider ?? idx.provider ?? provider}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.chunks")}</span><span class="v">${fmtNum(idx.chunks)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.files")}</span><span class="v">${fmtNum(idx.files)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v" style="font-size:11px">${idx.model ?? modelName}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v" style="font-size:11px">${idx.builtWith?.model ?? idx.model ?? modelName}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.dim")}</span><span class="v">${fmtNum(idx.dim)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.size")}</span><span class="v">${fmtBytes(idx.sizeBytes)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.lastBuild")}</span><span class="v">${fmtRelativeTime(idx.lastBuiltMs ?? null)}</span></div>
|
||||
${
|
||||
idx.compatible === false
|
||||
? html`
|
||||
<div class="rail-kv"><span class="k">${t("semantic.builtWith")}</span><span class="v" style="font-size:11px">${idx.builtWith?.provider} · ${idx.builtWith?.model}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.currentTarget")}</span><span class="v" style="font-size:11px">${idx.current?.provider} · ${idx.current?.model}</span></div>
|
||||
<div style="color:var(--c-warn);font-size:12px;padding-top:8px">${t("semantic.incompatibleHint")}</div>
|
||||
`
|
||||
: null
|
||||
}
|
||||
`
|
||||
: html`
|
||||
<div style="color:var(--fg-3);font-size:12.5px;padding:6px 0">
|
||||
${t("semantic.runIndexHint")}
|
||||
</div>
|
||||
`
|
||||
: html`<div style="color:var(--fg-3);font-size:12.5px;padding:6px 0">${t("semantic.runIndexHint")}</div>`
|
||||
}
|
||||
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap">
|
||||
<button class="primary" disabled=${busy || running || !ready} onClick=${() => start(false)}>${idx?.exists ? t("semantic.reIndex") : t("semantic.build")}</button>
|
||||
<button class="primary" disabled=${busy || running || !ready || semanticDraftBlocked} onClick=${() => start(false)}>${indexReady ? t("semantic.reIndex") : t("semantic.build")}</button>
|
||||
${
|
||||
idx?.exists
|
||||
? html`<button disabled=${busy || running || !ready} onClick=${() => start(true)}>${t("semantic.rebuild")}</button>`
|
||||
? html`<button disabled=${busy || running || !ready || semanticDraftBlocked} onClick=${() => start(true)}>${t("semantic.rebuild")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
running
|
||||
? html`<button onClick=${stop} style="border-color:var(--c-err);color:var(--c-err)">${t("semantic.stop")}</button>`
|
||||
: null
|
||||
}
|
||||
${running ? html`<button onClick=${stop} style="border-color:var(--c-err);color:var(--c-err)">${t("semantic.stop")}</button>` : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><span class="title">${t("semantic.ollama")}</span></div>
|
||||
<div class="rail-kv">
|
||||
<span class="k">${t("semantic.binary")}</span>
|
||||
<span class="v">${binaryFound ? html`<span class="pill ok">${t("semantic.found")}</span>` : html`<span class="pill err">${t("semantic.missing")}</span>`}</span>
|
||||
</div>
|
||||
<div class="rail-kv">
|
||||
<span class="k">${t("semantic.daemonStatus")}</span>
|
||||
<span class="v">${daemonRunning ? html`<span class="pill ok">${t("semantic.up")}</span>` : html`<span class="pill warn">${t("semantic.down")}</span>`}</span>
|
||||
</div>
|
||||
<div class="rail-kv">
|
||||
<span class="k">${t("semantic.model")}</span>
|
||||
<span class="v">${modelPulled ? html`<span class="pill ok">${t("semantic.pulled")}</span>` : html`<span class="pill warn">${t("semantic.missing")}</span>`}</span>
|
||||
</div>
|
||||
<div class="card-h"><span class="title">${isOllama ? t("semantic.ollama") : t("semantic.openaiCompat")}</span></div>
|
||||
${
|
||||
isOllama
|
||||
? html`
|
||||
<div class="rail-kv"><span class="k">${t("semantic.binary")}</span><span class="v">${binaryFound ? html`<span class="pill ok">${t("semantic.found")}</span>` : html`<span class="pill err">${t("semantic.missing")}</span>`}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.daemonStatus")}</span><span class="v">${daemonRunning ? html`<span class="pill ok">${t("semantic.up")}</span>` : html`<span class="pill warn">${t("semantic.down")}</span>`}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v">${modelPulled ? html`<span class="pill ok">${t("semantic.pulled")}</span>` : html`<span class="pill warn">${t("semantic.missing")}</span>`}</span></div>
|
||||
`
|
||||
: html`
|
||||
<div class="rail-kv"><span class="k">${t("semantic.apiUrl")}</span><span class="v" style="font-size:11px;max-width:160px;overflow-wrap:anywhere;word-break:break-word;text-align:right">${remote?.baseUrl ?? draft.openaiCompat.baseUrl}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.apiKey")}</span><span class="v">${remote?.apiKeySet ? html`<span class="pill ok">${t("semantic.found")}</span>` : html`<span class="pill warn">${t("semantic.missing")}</span>`}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v" style="font-size:11px">${remote?.model ?? draft.openaiCompat.model}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("semantic.extraBody")}</span><span class="v">${fmtNum(remote?.extraBodyKeys.length ?? 0)}</span></div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
<${SemanticExcludesCard} />
|
||||
@@ -326,6 +610,46 @@ export function SemanticPanel() {
|
||||
`;
|
||||
}
|
||||
|
||||
function toConfigDraft(config: SemanticConfigView): SemanticConfigDraft {
|
||||
return {
|
||||
provider: config.provider,
|
||||
ollama: {
|
||||
baseUrl: config.ollama.baseUrl,
|
||||
model: config.ollama.model,
|
||||
},
|
||||
openaiCompat: {
|
||||
baseUrl: config.openaiCompat.baseUrl,
|
||||
apiKey: "",
|
||||
model: config.openaiCompat.model,
|
||||
extraBodyText: JSON.stringify(config.openaiCompat.extraBody ?? {}, null, 2),
|
||||
apiKeySet: config.openaiCompat.apiKeySet,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateSemanticDraft(draft: SemanticConfigDraft): SemanticDraftValidation {
|
||||
if (draft.provider !== "openai-compat") {
|
||||
return { extraBody: {}, error: null };
|
||||
}
|
||||
const raw = draft.openaiCompat.extraBodyText.trim();
|
||||
if (!raw) {
|
||||
return { extraBody: {}, error: null };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
return {
|
||||
extraBody: {},
|
||||
error: t("semantic.invalidCustomRequestBody", { error: (err as Error).message }),
|
||||
};
|
||||
}
|
||||
if (!isPlainObject(parsed)) {
|
||||
return { extraBody: {}, error: t("semantic.customRequestBodyMustBeObject") };
|
||||
}
|
||||
return { extraBody: parsed, error: null };
|
||||
}
|
||||
|
||||
interface IndexConfig {
|
||||
excludeDirs?: string[];
|
||||
excludeFiles?: string[];
|
||||
@@ -367,6 +691,7 @@ interface SearchHit {
|
||||
interface SearchResponse {
|
||||
hits: SearchHit[];
|
||||
elapsedMs: number;
|
||||
provider?: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
@@ -422,13 +747,15 @@ function SemanticSearchSection() {
|
||||
hits || busy || error
|
||||
? html`
|
||||
<div style="font-family:var(--font-mono);font-size:11px;color:var(--fg-3);margin:8px 0 6px;display:flex;align-items:center;gap:8px">
|
||||
${busy
|
||||
? html`<span>${t("semantic.searching")}</span>`
|
||||
: error
|
||||
? html`<span style="color:var(--c-err)">${error}</span>`
|
||||
: hits
|
||||
? html`<span>${t("semantic.results", { count: hits.length, s: hits.length === 1 ? "" : "s", ms: meta?.elapsedMs ?? 0, model: meta?.model ?? "" })}</span>`
|
||||
: null}
|
||||
${
|
||||
busy
|
||||
? html`<span>${t("semantic.searching")}</span>`
|
||||
: error
|
||||
? html`<span style="color:var(--c-err)">${error}</span>`
|
||||
: hits
|
||||
? html`<span>${t("semantic.results", { count: hits.length, s: hits.length === 1 ? "" : "s", ms: meta?.elapsedMs ?? 0, model: meta?.model ?? "" })}</span>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
${
|
||||
hits && hits.length > 0
|
||||
@@ -449,8 +776,8 @@ function SemanticSearchSection() {
|
||||
</div>
|
||||
`
|
||||
: hits && hits.length === 0 && !busy
|
||||
? html`<div class="card" style="color:var(--fg-3);font-size:12px">${t("semantic.noMatches")}</div>`
|
||||
: null
|
||||
? html`<div class="card" style="color:var(--fg-3);font-size:12px">${t("semantic.noMatches")}</div>`
|
||||
: null
|
||||
}
|
||||
`
|
||||
: null
|
||||
@@ -570,11 +897,7 @@ function SemanticExcludesCard() {
|
||||
<div class="card-h">
|
||||
<span class="title">${t("semantic.indexConfig")}</span>
|
||||
<span class="meta">
|
||||
<a
|
||||
class="mono"
|
||||
style="color:var(--c-brand);text-decoration:none;font-size:11px;cursor:pointer"
|
||||
onClick=${reset}
|
||||
>${t("semantic.reset")}</a>
|
||||
<a class="mono" style="color:var(--c-brand);text-decoration:none;font-size:11px;cursor:pointer" onClick=${reset}>${t("semantic.reset")}</a>
|
||||
</span>
|
||||
</div>
|
||||
${info ? html`<div style="margin-bottom:8px"><span class="pill ok">${info}</span></div>` : null}
|
||||
@@ -606,11 +929,7 @@ function SemanticExcludesCard() {
|
||||
placeholder="**/*.test.ts"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="checkbox-row"
|
||||
style="margin-top:8px;cursor:pointer"
|
||||
onClick=${() => setDraft({ ...draft, respectGitignore: !draft.respectGitignore })}
|
||||
>
|
||||
<div class="checkbox-row" style="margin-top:8px;cursor:pointer" onClick=${() => setDraft({ ...draft, respectGitignore: !draft.respectGitignore })}>
|
||||
<span class=${`box ${draft.respectGitignore ? "on" : ""}`}>${draft.respectGitignore ? "✓" : ""}</span>
|
||||
<span>${t("semantic.respectGitignore")}</span>
|
||||
</div>
|
||||
@@ -623,17 +942,14 @@ function SemanticExcludesCard() {
|
||||
min="1024"
|
||||
step="1024"
|
||||
value=${draft.maxFileBytes}
|
||||
onInput=${(e: Event) =>
|
||||
setDraft({ ...draft, maxFileBytes: Number((e.target as HTMLInputElement).value) || 0 })}
|
||||
onInput=${(e: Event) => setDraft({ ...draft, maxFileBytes: Number((e.target as HTMLInputElement).value) || 0 })}
|
||||
style="font-size:12px"
|
||||
/>
|
||||
<span class="help">${t("semantic.skipLarger", { size: (draft.maxFileBytes / 1024 / 1024).toFixed(1) })}</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:6px;margin-top:10px">
|
||||
<button class="btn ghost" style="flex:1" disabled=${busy} onClick=${runPreview}>
|
||||
<span class="g">⊕</span><span>${t("semantic.preview")}</span>
|
||||
</button>
|
||||
<button class="btn ghost" style="flex:1" disabled=${busy} onClick=${runPreview}><span class="g">⊕</span><span>${t("semantic.preview")}</span></button>
|
||||
<button class="btn primary" style="flex:1" disabled=${busy} onClick=${save}>${t("common.save")}</button>
|
||||
</div>
|
||||
|
||||
@@ -659,26 +975,24 @@ function ExcludesPreview({ preview }: { preview: PreviewData }) {
|
||||
].filter((k) => (buckets[k] || 0) > 0);
|
||||
return html`
|
||||
<div class="excludes-preview">
|
||||
<div class="summary">
|
||||
${t("semantic.previewSummary", { included: preview.filesIncluded, skipped: totalSkipped })}
|
||||
</div>
|
||||
<div class="summary">${t("semantic.previewSummary", { included: preview.filesIncluded, skipped: totalSkipped })}</div>
|
||||
${
|
||||
reasons.length === 0
|
||||
? html`<div style="color:var(--fg-3)">${t("semantic.nothingSkipped")}</div>`
|
||||
: reasons.map(
|
||||
(r) => html`
|
||||
<details>
|
||||
<summary><strong>${r}: ${buckets[r]}</strong></summary>
|
||||
<ul>
|
||||
${(samples[r] || []).map((p) => html`<li><code>${p}</code></li>`)}
|
||||
${
|
||||
(buckets[r] || 0) > (samples[r] || []).length
|
||||
? html`<li style="color:var(--fg-3)">…${(buckets[r] || 0) - (samples[r] || []).length} more</li>`
|
||||
: null
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
`,
|
||||
<details>
|
||||
<summary><strong>${r}: ${buckets[r]}</strong></summary>
|
||||
<ul>
|
||||
${(samples[r] || []).map((p) => html`<li><code>${p}</code></li>`)}
|
||||
${
|
||||
(buckets[r] || 0) > (samples[r] || []).length
|
||||
? html`<li style="color:var(--fg-3)">…${(buckets[r] || 0) - (samples[r] || []).length} more</li>`
|
||||
: null
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
`,
|
||||
)
|
||||
}
|
||||
${
|
||||
@@ -723,14 +1037,11 @@ function ChipFormRow({
|
||||
};
|
||||
return html`
|
||||
<div class="form-row">
|
||||
<span class="lbl">
|
||||
${label}
|
||||
${sub ? html`<span style="color:var(--fg-3);font-weight:400;text-transform:none;letter-spacing:0"> · ${sub}</span>` : null}
|
||||
</span>
|
||||
<span class="lbl">${label}${sub ? html`<span style="color:var(--fg-3);font-weight:400;text-transform:none;letter-spacing:0"> · ${sub}</span>` : null}</span>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||
${value.map(
|
||||
(e) => html`
|
||||
<span class="chip-f">
|
||||
<span class="chip-f static">
|
||||
<span>${e}</span>
|
||||
<span class="x" style="cursor:pointer" onClick=${() => remove(e)} title="remove">×</span>
|
||||
</span>
|
||||
@@ -758,30 +1069,38 @@ function ChipFormRow({
|
||||
function SemanticJobView({ job, running }: { job: SemanticJob; running: boolean }) {
|
||||
useLang();
|
||||
const phaseLabel =
|
||||
({
|
||||
scan: t("semantic.phaseScan"),
|
||||
embed: t("semantic.phaseEmbed"),
|
||||
write: t("semantic.phaseWrite"),
|
||||
done: t("semantic.phaseDone"),
|
||||
error: t("semantic.phaseError"),
|
||||
} as Record<string, string>)[job.phase] ?? job.phase;
|
||||
(
|
||||
{
|
||||
setup: t("semantic.phaseSetup"),
|
||||
scan: t("semantic.phaseScan"),
|
||||
embed: t("semantic.phaseEmbed"),
|
||||
write: t("semantic.phaseWrite"),
|
||||
done: t("semantic.phaseDone"),
|
||||
error: t("semantic.phaseError"),
|
||||
cancelled: t("semantic.phaseCancelled"),
|
||||
} as Record<string, string>
|
||||
)[job.phase] ?? job.phase;
|
||||
const total = job.chunksTotal ?? 0;
|
||||
const doneN = job.chunksDone ?? 0;
|
||||
const ratio = total > 0 ? Math.min(1, doneN / total) : 0;
|
||||
const elapsed = ((Date.now() - job.startedAt) / 1000).toFixed(1);
|
||||
const elapsedBase = job.finishedAt ?? Date.now();
|
||||
const elapsedSeconds = (elapsedBase - job.startedAt) / 1000;
|
||||
const elapsed = elapsedSeconds < 0.1 ? "<0.1s" : `${elapsedSeconds.toFixed(1)}s`;
|
||||
const phaseSummary =
|
||||
job.phase === "error" && job.lastPhase === "setup"
|
||||
? t("semantic.setupFailed")
|
||||
: phaseLabel;
|
||||
|
||||
return html`
|
||||
<div class="kv">
|
||||
<div><span class="kv-key">phase</span>
|
||||
<span class=${`pill ${job.phase === "error" ? "pill-err" : running ? "pill-active" : "pill-dim"}`}>${phaseLabel}</span>
|
||||
${job.aborted ? html`<span class="pill warn" style="margin-left: 6px;">${t("semantic.stopping")}</span>` : null}
|
||||
<span style="color:var(--fg-3);margin-left:8px">${elapsed}s</span>
|
||||
<span class=${`pill ${job.phase === "error" ? "pill-err" : job.phase === "cancelled" ? "warn" : running ? "pill-active" : "pill-dim"}`}>${phaseSummary}</span>
|
||||
${job.aborted && running ? html`<span class="pill warn" style="margin-left: 6px;">${t("semantic.stopping")}</span>` : null}
|
||||
<span style="color:var(--fg-3);margin-left:8px">${elapsed}</span>
|
||||
</div>
|
||||
${
|
||||
job.filesScanned !== null && job.filesScanned !== undefined
|
||||
? html`<div><span class="kv-key">${t("semantic.files")}</span>${t("semantic.scanned", { count: job.filesScanned })}${
|
||||
job.filesChanged != null ? ` · ${t("semantic.changed", { count: job.filesChanged })}` : ""
|
||||
}${job.filesSkipped ? ` · ${t("semantic.skipped", { count: job.filesSkipped })}` : ""}</div>`
|
||||
? html`<div><span class="kv-key">${t("semantic.files")}</span>${t("semantic.scanned", { count: job.filesScanned })}${job.filesChanged != null ? ` · ${t("semantic.changed", { count: job.filesChanged })}` : ""}${job.filesSkipped ? ` · ${t("semantic.skipped", { count: job.filesSkipped })}` : ""}</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
@@ -796,23 +1115,13 @@ function SemanticJobView({ job, running }: { job: SemanticJob; running: boolean
|
||||
`
|
||||
: null
|
||||
}
|
||||
${
|
||||
job.error
|
||||
? html`<div><span class="kv-key">${t("semantic.phaseError")}</span><span class="err">${job.error}</span></div>`
|
||||
: null
|
||||
}
|
||||
${job.error ? html`<div><span class="kv-key">${t("semantic.phaseError")}</span><span class="err">${job.error}</span></div>` : null}
|
||||
${
|
||||
job.result
|
||||
? html`<div><span class="kv-key">${t("semantic.result")}</span>${t("semantic.added", { count: job.result.chunksAdded })} · ${t("semantic.removed", { count: job.result.chunksRemoved })}${
|
||||
job.result.chunksSkipped ? ` · ${t("semantic.failed", { count: job.result.chunksSkipped })}` : ""
|
||||
} · ${(job.result.durationMs / 1000).toFixed(1)}s</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
job.result?.skipBuckets
|
||||
? html`<${SkipBucketsView} buckets=${job.result.skipBuckets} />`
|
||||
? html`<div><span class="kv-key">${t("semantic.result")}</span>${t("semantic.added", { count: job.result.chunksAdded })} · ${t("semantic.removed", { count: job.result.chunksRemoved })}${job.result.chunksSkipped ? ` · ${t("semantic.failed", { count: job.result.chunksSkipped })}` : ""} · ${(job.result.durationMs / 1000).toFixed(1)}s</div>`
|
||||
: null
|
||||
}
|
||||
${job.result?.skipBuckets ? html`<${SkipBucketsView} buckets=${job.result.skipBuckets} />` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -836,3 +1145,13 @@ function SkipBucketsView({ buckets }: { buckets: Record<string, number> }) {
|
||||
.map(([k, label]) => `${label}: ${buckets[k]}`);
|
||||
return html`<div><span class="kv-key">${t("semantic.skipped")}</span>${t("semantic.skippedFiles", { total, details: parts.join(", ") })}</div>`;
|
||||
}
|
||||
|
||||
function isActiveSemanticPhase(phase: string | undefined): boolean {
|
||||
return phase === "setup" || phase === "scan" || phase === "embed" || phase === "write";
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function SessionsPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
|
||||
<span class="chip-f static active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
|
||||
</div>
|
||||
<div class="ssl-rows">
|
||||
${filtered.map(
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { api } from "../lib/api.js";
|
||||
import {
|
||||
type BudgetState,
|
||||
QUICK_CAPS_USD,
|
||||
budgetTone,
|
||||
bumpSuggestions,
|
||||
deriveBudgetState,
|
||||
} from "../lib/budget.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import {
|
||||
INTERVAL_PRESETS_MS,
|
||||
type IntervalUnit,
|
||||
type LoopRunStatus,
|
||||
formatRemaining,
|
||||
parseCustomInterval,
|
||||
} from "../lib/loop-control.js";
|
||||
import { type DashboardLang, getLang, setLang, t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface SettingsData {
|
||||
@@ -11,6 +25,364 @@ interface SettingsData {
|
||||
search?: boolean;
|
||||
model?: string;
|
||||
editMode?: string;
|
||||
proNext?: boolean;
|
||||
budgetUsd?: number | null;
|
||||
/** Cumulative session spend (USD); null when no session is attached. */
|
||||
sessionSpendUsd?: number | null;
|
||||
}
|
||||
|
||||
function fmtUsd2(n: number): string {
|
||||
return `$${n.toFixed(n < 1 ? 4 : 2)}`;
|
||||
}
|
||||
|
||||
interface ModelPriceEntry {
|
||||
inputCacheHit: number;
|
||||
inputCacheMiss: number;
|
||||
output: number;
|
||||
}
|
||||
|
||||
interface ModelCatalog {
|
||||
models: string[] | null;
|
||||
current: string | null;
|
||||
pricing: Record<string, ModelPriceEntry>;
|
||||
}
|
||||
|
||||
function formatPricing(p: ModelPriceEntry | undefined): string | null {
|
||||
if (!p) return null;
|
||||
return t("settings.modelPricingLine", {
|
||||
hit: p.inputCacheHit.toFixed(3),
|
||||
miss: p.inputCacheMiss.toFixed(3),
|
||||
out: p.output.toFixed(3),
|
||||
});
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
current,
|
||||
catalog,
|
||||
saving,
|
||||
onPick,
|
||||
}: {
|
||||
current: string;
|
||||
catalog: ModelCatalog | null;
|
||||
saving: boolean;
|
||||
onPick: (model: string) => void;
|
||||
}) {
|
||||
const list = catalog?.models ?? null;
|
||||
const ready = list && list.length > 0;
|
||||
if (!ready) {
|
||||
// Fallback: catalog hasn't loaded (or API failed). Read-only — same as before D-4.
|
||||
return html`<code class="mono">${current ?? "—"}</code>`;
|
||||
}
|
||||
// Ensure the live model is selectable even if the catalog hasn't reported it
|
||||
// yet (preset overrides, custom IDs).
|
||||
const options = list.includes(current) ? list : [current, ...list];
|
||||
const price = catalog?.pricing[current];
|
||||
return html`
|
||||
<span style="display:inline-flex;flex-direction:column;gap:4px">
|
||||
<select
|
||||
value=${current}
|
||||
onChange=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
if (next && next !== current) onPick(next);
|
||||
}}
|
||||
disabled=${saving}
|
||||
style="font-family:var(--font-mono);min-width:200px"
|
||||
>
|
||||
${options.map((m) => html`<option key=${m} value=${m}>${m}</option>`)}
|
||||
</select>
|
||||
${
|
||||
price
|
||||
? html`<span style="color:var(--fg-3);font-size:11px;font-family:var(--font-mono)">${formatPricing(price)}</span>`
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function BudgetGauge({ state }: { state: BudgetState }) {
|
||||
if (state.kind === "off") return null;
|
||||
const tone = budgetTone(state);
|
||||
const fill = Math.min(100, state.pct);
|
||||
const valueColor =
|
||||
tone === "err"
|
||||
? "color:var(--c-err)"
|
||||
: tone === "warn"
|
||||
? "color:var(--c-warn)"
|
||||
: "color:var(--fg-1)";
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;font-size:13px">
|
||||
<span style=${valueColor}>
|
||||
<strong style="font-family:var(--font-mono)">${fmtUsd2(state.spent)}</strong>
|
||||
<span style="color:var(--fg-3)"> ${t("settings.budgetOf")} </span>
|
||||
<strong style="font-family:var(--font-mono)">${fmtUsd2(state.cap)}</strong>
|
||||
</span>
|
||||
<span style=${`font-family:var(--font-mono);font-size:11px;${valueColor}`}>${state.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class=${`progress ${tone}`}><div class="progress-fill" style=${`width:${fill}%`}></div></div>
|
||||
<span style="color:var(--fg-3);font-size:11px">
|
||||
${
|
||||
state.kind === "exhausted"
|
||||
? t("settings.budgetRefusing")
|
||||
: state.kind === "warn"
|
||||
? t("settings.budgetWarnLine")
|
||||
: t("settings.budgetIdleLine")
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface BudgetSectionProps {
|
||||
state: BudgetState;
|
||||
saving: boolean;
|
||||
onSetCap: (usd: number) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function BudgetSection({ state, saving, onSetCap, onClear }: BudgetSectionProps) {
|
||||
const [custom, setCustom] = useState("");
|
||||
const submitCustom = () => {
|
||||
const n = Number.parseFloat(custom);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
onSetCap(n);
|
||||
setCustom("");
|
||||
}
|
||||
};
|
||||
|
||||
const quickButtons = (caps: ReadonlyArray<number>) =>
|
||||
caps.map(
|
||||
(c) => html`
|
||||
<button
|
||||
key=${c}
|
||||
class="btn"
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
onClick=${() => onSetCap(c)}
|
||||
>$${c}</button>
|
||||
`,
|
||||
);
|
||||
|
||||
const customField = html`
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.budgetCustom")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value=${custom}
|
||||
placeholder="0.00"
|
||||
onInput=${(e: Event) => setCustom((e.target as HTMLInputElement).value)}
|
||||
onKeyDown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") submitCustom();
|
||||
}}
|
||||
style="width:72px;font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
/>
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled=${saving || !(Number.parseFloat(custom) > 0)}
|
||||
onClick=${submitCustom}
|
||||
>→</button>
|
||||
</span>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:12px">
|
||||
<${BudgetGauge} state=${state} />
|
||||
|
||||
${
|
||||
state.kind === "off"
|
||||
? html`
|
||||
<div>
|
||||
<div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t("settings.budgetSetCap")}</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${quickButtons(QUICK_CAPS_USD)}
|
||||
${customField}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: state.kind === "warn" || state.kind === "exhausted"
|
||||
? html`
|
||||
<div>
|
||||
<div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t("settings.budgetBumpHint")}</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${bumpSuggestions(state.cap).map(
|
||||
(next) => html`
|
||||
<button
|
||||
key=${next}
|
||||
class="btn primary"
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
onClick=${() => onSetCap(next)}
|
||||
>→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
|
||||
`,
|
||||
)}
|
||||
${customField}
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="btn" disabled=${saving} onClick=${onClear}>${t("settings.budgetClear")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${bumpSuggestions(state.cap).map(
|
||||
(next) => html`
|
||||
<button
|
||||
key=${next}
|
||||
class="btn"
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${saving}
|
||||
onClick=${() => onSetCap(next)}
|
||||
>→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
|
||||
`,
|
||||
)}
|
||||
${customField}
|
||||
<button
|
||||
class="btn"
|
||||
style="margin-left:8px"
|
||||
disabled=${saving}
|
||||
onClick=${onClear}
|
||||
>${t("settings.budgetClear")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface LoopSectionProps {
|
||||
status: LoopRunStatus | null;
|
||||
/** ms remaining until next fire — ticks down client-side between status polls. */
|
||||
remainingMs: number;
|
||||
/** Last-turn cost in USD; used as a hint for "each iteration costs ~". */
|
||||
avgIterCostUsd: number | null;
|
||||
busy: boolean;
|
||||
onStart: (intervalMs: number, prompt: string) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
function LoopSection({
|
||||
status,
|
||||
remainingMs,
|
||||
avgIterCostUsd,
|
||||
busy,
|
||||
onStart,
|
||||
onStop,
|
||||
}: LoopSectionProps) {
|
||||
const [intervalMs, setIntervalMs] = useState<number>(INTERVAL_PRESETS_MS[1]!.ms);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
const [customUnit, setCustomUnit] = useState<IntervalUnit>("m");
|
||||
|
||||
if (status) {
|
||||
return html`
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline">
|
||||
<span style="color:var(--c-warn);font-family:var(--font-mono);font-size:11px">⟳ ${t("settings.loopRunning")}</span>
|
||||
<span style="color:var(--fg-3);font-size:11px">
|
||||
${t("settings.loopIter", { iter: status.iter })} · ${t("settings.loopFiresIn", { remaining: formatRemaining(remainingMs) })}
|
||||
</span>
|
||||
</div>
|
||||
<div style="background:var(--bg-elev-2);border:1px solid var(--bd);border-radius:var(--r);padding:8px 10px;font-family:var(--font-mono);font-size:12px;color:var(--fg-1);white-space:pre-wrap;max-height:120px;overflow-y:auto">${status.prompt}</div>
|
||||
<div>
|
||||
<button class="btn danger" disabled=${busy} onClick=${onStop}>${t("settings.loopStop")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const customMs = parseCustomInterval(customValue, customUnit);
|
||||
const canStart = !busy && intervalMs > 0 && prompt.trim().length > 0;
|
||||
|
||||
return html`
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="color:var(--fg-3);font-size:11px">
|
||||
${t("settings.loopIdleHint")}
|
||||
${
|
||||
typeof avgIterCostUsd === "number" && avgIterCostUsd > 0
|
||||
? html` ${t("settings.loopCostHint", { cost: `$${avgIterCostUsd.toFixed(4)}` })}`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopInterval")}</span>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
${INTERVAL_PRESETS_MS.map(
|
||||
(p) => html`
|
||||
<button
|
||||
key=${p.ms}
|
||||
class=${`btn ${intervalMs === p.ms && customValue === "" ? "primary" : ""}`}
|
||||
style="font-family:var(--font-mono)"
|
||||
disabled=${busy}
|
||||
onClick=${() => {
|
||||
setIntervalMs(p.ms);
|
||||
setCustomValue("");
|
||||
}}
|
||||
>${p.label}</button>
|
||||
`,
|
||||
)}
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopCustom")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value=${customValue}
|
||||
onInput=${(e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
setCustomValue(raw);
|
||||
const ms = parseCustomInterval(raw, customUnit);
|
||||
if (ms !== null) setIntervalMs(ms);
|
||||
}}
|
||||
style="width:64px;font-family:var(--font-mono)"
|
||||
disabled=${busy}
|
||||
/>
|
||||
<select
|
||||
value=${customUnit}
|
||||
onChange=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value as IntervalUnit;
|
||||
setCustomUnit(next);
|
||||
if (customValue) {
|
||||
const ms = parseCustomInterval(customValue, next);
|
||||
if (ms !== null) setIntervalMs(ms);
|
||||
}
|
||||
}}
|
||||
disabled=${busy}
|
||||
>
|
||||
<option value="s">s</option>
|
||||
<option value="m">m</option>
|
||||
<option value="h">h</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
${
|
||||
customValue && customMs === null
|
||||
? html`<span style="color:var(--c-err);font-size:11px">${t("settings.loopRangeError")}</span>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopPrompt")}</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
placeholder=${t("settings.loopPromptPlaceholder")}
|
||||
value=${prompt}
|
||||
onInput=${(e: Event) => setPrompt((e.target as HTMLTextAreaElement).value)}
|
||||
style="width:100%;font-family:var(--font-mono);resize:vertical"
|
||||
disabled=${busy}
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn primary"
|
||||
disabled=${!canStart}
|
||||
onClick=${() => onStart(intervalMs, prompt.trim())}
|
||||
>${t("settings.loopStart")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function SettingsPanel() {
|
||||
@@ -20,6 +392,13 @@ export function SettingsPanel() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Partial<SettingsData>>({});
|
||||
const [catalog, setCatalog] = useState<ModelCatalog | null>(null);
|
||||
const [loopStatus, setLoopStatus] = useState<LoopRunStatus | null>(null);
|
||||
const [loopAvgCost, setLoopAvgCost] = useState<number | null>(null);
|
||||
const [loopBusy, setLoopBusy] = useState(false);
|
||||
/** Wall-clock time of the last status sync — used to interpolate the countdown. */
|
||||
const lastStatusSyncRef = useRef<number>(0);
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -33,6 +412,73 @@ export function SettingsPanel() {
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
api<ModelCatalog>("/models")
|
||||
.then(setCatalog)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const refreshLoop = useCallback(async () => {
|
||||
try {
|
||||
const r = await api<{ status: LoopRunStatus | null }>("/loop/status");
|
||||
setLoopStatus(r.status);
|
||||
lastStatusSyncRef.current = Date.now();
|
||||
} catch {
|
||||
/* ignore — status is best-effort */
|
||||
}
|
||||
try {
|
||||
const r = await api<{ stats?: { lastTurnCostUsd?: number } }>("/overview");
|
||||
setLoopAvgCost(r.stats?.lastTurnCostUsd ?? null);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
refreshLoop();
|
||||
const id = setInterval(() => {
|
||||
if (!cancelled) refreshLoop();
|
||||
}, 5_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [refreshLoop]);
|
||||
useEffect(() => {
|
||||
if (!loopStatus) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => clearInterval(id);
|
||||
}, [loopStatus]);
|
||||
|
||||
const remainingMs = loopStatus
|
||||
? Math.max(0, loopStatus.nextFireMs - (now - lastStatusSyncRef.current))
|
||||
: 0;
|
||||
|
||||
const startLoop = useCallback(
|
||||
async (intervalMs: number, prompt: string) => {
|
||||
setLoopBusy(true);
|
||||
try {
|
||||
await api("/loop/start", { method: "POST", body: { intervalMs, prompt } });
|
||||
await refreshLoop();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoopBusy(false);
|
||||
}
|
||||
},
|
||||
[refreshLoop],
|
||||
);
|
||||
const stopLoop = useCallback(async () => {
|
||||
setLoopBusy(true);
|
||||
try {
|
||||
await api("/loop/stop", { method: "POST" });
|
||||
await refreshLoop();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoopBusy(false);
|
||||
}
|
||||
}, [refreshLoop]);
|
||||
|
||||
const save = useCallback(
|
||||
async (fields: Partial<SettingsData>) => {
|
||||
@@ -189,11 +635,50 @@ export function SettingsPanel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionCompute"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.proNext"),
|
||||
html`
|
||||
<button
|
||||
class=${`btn ${v.proNext ? "primary" : ""}`}
|
||||
onClick=${() => save({ proNext: !v.proNext })}
|
||||
disabled=${saving}
|
||||
>${v.proNext ? t("settings.proArmed") : t("settings.proArm")}</button>
|
||||
`,
|
||||
t("settings.proNextNote"),
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionBudget"))}
|
||||
<${BudgetSection}
|
||||
state=${deriveBudgetState(v.budgetUsd, v.sessionSpendUsd)}
|
||||
saving=${saving}
|
||||
onSetCap=${(usd: number) => save({ budgetUsd: usd })}
|
||||
onClear=${() => save({ budgetUsd: null })}
|
||||
/>
|
||||
|
||||
${sectionH3(t("settings.sectionLoop"))}
|
||||
<${LoopSection}
|
||||
status=${loopStatus}
|
||||
remainingMs=${remainingMs}
|
||||
avgIterCostUsd=${loopAvgCost}
|
||||
busy=${loopBusy}
|
||||
onStart=${startLoop}
|
||||
onStop=${stopLoop}
|
||||
/>
|
||||
|
||||
${sectionH3(t("settings.sectionRuntime"))}
|
||||
<div class="card">
|
||||
${fieldRow(
|
||||
t("settings.activeModel"),
|
||||
html`<code class="mono">${v.model ?? "—"}</code>`,
|
||||
html`<${ModelRow}
|
||||
current=${v.model ?? "—"}
|
||||
catalog=${catalog}
|
||||
saving=${saving}
|
||||
onPick=${(m: string) => save({ model: m })}
|
||||
/>`,
|
||||
t("settings.appliesNextTurn"),
|
||||
)}
|
||||
${fieldRow(
|
||||
t("settings.editMode"),
|
||||
|
||||
@@ -29,6 +29,7 @@ export function SkillsPanel() {
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newScope, setNewScope] = useState<"global" | "project">("global");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [scopeFilter, setScopeFilter] = useState<"all" | Scope>("all");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -120,13 +121,15 @@ export function SkillsPanel() {
|
||||
...data.global.map((s) => ({ scope: "global" as Scope, ...s })),
|
||||
...data.builtin.map((s) => ({ scope: "builtin" as Scope, ...s })),
|
||||
];
|
||||
const scopeFiltered =
|
||||
scopeFilter === "all" ? allWith : allWith.filter((s) => s.scope === scopeFilter);
|
||||
const filtered = filter.trim()
|
||||
? allWith.filter(
|
||||
? scopeFiltered.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(s.description ?? "").toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: allWith;
|
||||
: scopeFiltered;
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
@@ -141,10 +144,22 @@ export function SkillsPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${allWith.length}</span></span>
|
||||
<span class="chip-f">${t("skills.project")} <span class="ct">${data.project.length}</span></span>
|
||||
<span class="chip-f">${t("skills.global")} <span class="ct">${data.global.length}</span></span>
|
||||
<span class="chip-f">${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("all")}
|
||||
>${t("common.all")} <span class="ct">${allWith.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "project" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("project")}
|
||||
>${t("skills.project")} <span class="ct">${data.project.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "global" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("global")}
|
||||
>${t("skills.global")} <span class="ct">${data.global.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "builtin" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("builtin")}
|
||||
>${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
|
||||
</div>
|
||||
|
||||
<div style="padding:0 12px 8px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fmtBytes, fmtNum } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { compareVersions } from "../lib/version.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
interface HealthData {
|
||||
@@ -22,7 +23,7 @@ export function SystemPanel() {
|
||||
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "health", error: error.message })}</div>`;
|
||||
if (!data) return null;
|
||||
const h = data;
|
||||
const upToDate = h.latestVersion ? h.latestVersion === h.version : null;
|
||||
const upToDate = h.latestVersion ? compareVersions(h.version, h.latestVersion) >= 0 : null;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
@@ -37,8 +37,8 @@ export function ToolsPanel() {
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${d.total}</span></span>
|
||||
${d.planMode ? html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
|
||||
<span class="chip-f static active">${t("common.all")} <span class="ct">${d.total}</span></span>
|
||||
${d.planMode ? html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
@@ -36,6 +36,7 @@ interface UsageDay {
|
||||
function UsageChart({ days }: { days: UsageDay[] }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const plotRef = useRef<UPlotInstance | null>(null);
|
||||
useLang();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -64,7 +65,7 @@ function UsageChart({ days }: { days: UsageDay[] }) {
|
||||
{ stroke: "#94a3b8", grid: { stroke: "rgba(148, 163, 184, 0.08)" } },
|
||||
{
|
||||
scale: "y",
|
||||
label: "USD",
|
||||
label: t("usage.axisUsd"),
|
||||
stroke: "#94a3b8",
|
||||
grid: { stroke: "rgba(148, 163, 184, 0.08)" },
|
||||
values: (_u: unknown, v: number[]) => v.map((n) => `$${n.toFixed(4)}`),
|
||||
@@ -72,17 +73,22 @@ function UsageChart({ days }: { days: UsageDay[] }) {
|
||||
{
|
||||
scale: "turns",
|
||||
side: 1,
|
||||
label: "turns",
|
||||
label: t("usage.axisTurns"),
|
||||
stroke: "#94a3b8",
|
||||
grid: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{ label: "cost", stroke: "#67e8f9", width: 2, fill: "rgba(103, 232, 249, 0.10)" },
|
||||
{ label: "cache saved", stroke: "#5eead4", width: 2, dash: [4, 4] },
|
||||
{ label: t("usage.axisTime") },
|
||||
{
|
||||
label: "turns",
|
||||
label: t("usage.seriesCost"),
|
||||
stroke: "#67e8f9",
|
||||
width: 2,
|
||||
fill: "rgba(103, 232, 249, 0.10)",
|
||||
},
|
||||
{ label: t("usage.seriesCacheSaved"), stroke: "#5eead4", width: 2, dash: [4, 4] },
|
||||
{
|
||||
label: t("usage.seriesTurns"),
|
||||
stroke: "#c4b5fd",
|
||||
scale: "turns",
|
||||
width: 1.5,
|
||||
@@ -174,8 +180,8 @@ export function UsagePanel() {
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
|
||||
<span class="chip-f">${u.logSize}</span>
|
||||
<span class="chip-f static active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
|
||||
<span class="chip-f static">${u.logSize}</span>
|
||||
</div>
|
||||
|
||||
${
|
||||
@@ -203,13 +209,13 @@ export function UsagePanel() {
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>${t("usage.colTurns")}</th>
|
||||
<th>${t("usage.colCacheHit")}</th>
|
||||
<th>${t("usage.colCost")}</th>
|
||||
<th>${t("usage.colCacheSaved")}</th>
|
||||
<th>${t("usage.colVsClaude")}</th>
|
||||
<th>${t("usage.colSaved")}</th>
|
||||
<th>${t("usage.colWindow")}</th>
|
||||
<th class="num">${t("usage.colTurns")}</th>
|
||||
<th class="num">${t("usage.colCacheHit")}</th>
|
||||
<th class="num">${t("usage.colCost")}</th>
|
||||
<th class="num">${t("usage.colCacheSaved")}</th>
|
||||
<th class="num">${t("usage.colVsClaude")}</th>
|
||||
<th class="num">${t("usage.colSaved")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
+37
-38
@@ -43,27 +43,30 @@ timestamps each turn — cache hit rate in practice: <20%.
|
||||
**Metric.** `prompt_cache_hit_tokens / (hit + miss)` exposed per-turn and
|
||||
aggregated per-session. Visible in the TUI's top-bar cache cell.
|
||||
|
||||
### Pillar 2 — R1 Thought Harvesting *(opt-in)*
|
||||
#### Parallel tool dispatch
|
||||
|
||||
**Problem.** R1 / V4-thinking emits extensive `reasoning_content`. DeepSeek's
|
||||
own docs recommend *not* feeding it back to the next turn. Most frameworks
|
||||
display it to the user and discard. The planning signal inside is lost.
|
||||
Each tool declares `parallelSafe?: boolean` (default `false`). The loop
|
||||
dispatcher groups consecutive parallel-safe calls into chunks and races
|
||||
them via `Promise.allSettled`; the first non-parallel-safe call ends the
|
||||
chunk and runs alone (serial barrier — read-after-write order
|
||||
preserved). Tool-result yields and history append still land in declared
|
||||
order regardless of which call settles first, so the model sees the
|
||||
same shape it would under a fully serial dispatch.
|
||||
|
||||
**Solution.** A two-stage process:
|
||||
| Env var | Default | Effect |
|
||||
|---|---|---|
|
||||
| `REASONIX_PARALLEL_MAX` | `3` (hard cap `16`) | Max chunk size. |
|
||||
| `REASONIX_TOOL_DISPATCH=serial` | unset | Forces serial dispatch — escape hatch. |
|
||||
|
||||
```
|
||||
R1 output → Harvester (v4-flash, thinking off) → TypedPlanState
|
||||
├─ subgoals: string[]
|
||||
├─ hypotheses: string[]
|
||||
├─ uncertainties: string[]
|
||||
└─ rejectedPaths: string[]
|
||||
```
|
||||
Built-in opt-ins: read-only filesystem (`read_file`, `list_directory`,
|
||||
`directory_tree`, `search_files`, `search_content`, `get_file_info`),
|
||||
web (`web_search`, `web_fetch`), `recall_memory`, `semantic_search`,
|
||||
isolated child loops (`run_skill`, `spawn_subagent`), in-memory job
|
||||
queries (`job_output`, `list_jobs`). Mutating / side-effecting tools
|
||||
stay default. MCP-bridged tools default `false` — third-party tools
|
||||
opt in only when the server explicitly declares parallel safety.
|
||||
|
||||
The harvester is a cheap flash call with a strict JSON schema. Output is
|
||||
validated at runtime. **Not enabled in any preset** (see Pillar 4) — the
|
||||
additional round-trip rarely pays back, so it's `/harvest on` opt-in.
|
||||
|
||||
### Pillar 3 — Tool-Call Repair
|
||||
### Pillar 2 — Tool-Call Repair
|
||||
|
||||
**Problem.** Empirical DeepSeek failure modes:
|
||||
- Tool-call JSON emitted inside `<think>`, missing from the final message.
|
||||
@@ -83,7 +86,7 @@ additional round-trip rarely pays back, so it's `/harvest on` opt-in.
|
||||
4. **`storm`** — identical `(tool, args)` tuple within a sliding window →
|
||||
suppress the call, inject a reflection turn.
|
||||
|
||||
### Pillar 4 — Cost Control *(v0.6)*
|
||||
### Pillar 3 — Cost Control *(v0.6)*
|
||||
|
||||
**Problem.** Coding agents that default to the frontier model (v4-pro, ~12×
|
||||
flash cost) and accumulate full tool results in context are $150-$250/month
|
||||
@@ -95,17 +98,13 @@ tuning in the common case:
|
||||
|
||||
#### 4.1 Tiered defaults (flash-first)
|
||||
|
||||
The three presets now trade only **model tier** and **reasoning effort**:
|
||||
The three presets trade **model tier** and **reasoning effort**:
|
||||
|
||||
| Preset | Model | Effort | Harvest | Branch | Cost |
|
||||
|---|---|---|---|---|---:|
|
||||
| `fast` | `v4-flash` | `high` | off | 1 | 1× |
|
||||
| `smart` (default) | `v4-flash` | `max` | off | 1 | ~1.5× |
|
||||
| `max` | `v4-pro` | `max` | off | 1 | ~12× |
|
||||
|
||||
**Branching and harvest are never in a preset.** Both multiply cost and
|
||||
rarely pay back; `/branch N` and `/harvest on` are pure opt-ins. A user can
|
||||
run for weeks without ever touching them.
|
||||
| Preset | Model | Effort | Cost |
|
||||
|---|---|---|---:|
|
||||
| `flash` | `v4-flash` | `max` | 1× |
|
||||
| `auto` (default) | `v4-flash` → `v4-pro` on hard turns | `max` | 1–3× |
|
||||
| `pro` | `v4-pro` | `max` | ~12× |
|
||||
|
||||
All auxiliary calls — `forceSummaryAfterIterLimit`, subagent spawns,
|
||||
truncation repair retries — hard-code `v4-flash + effort=high` regardless
|
||||
@@ -153,9 +152,8 @@ Per-turn and session cost are colored in the StatsPanel:
|
||||
```
|
||||
src/
|
||||
├── client.ts # DeepSeek client (fetch + SSE)
|
||||
├── loop.ts # Pillar 1 + 4 — CacheFirstLoop
|
||||
├── harvest.ts # Pillar 2 — flash-backed plan-state extraction
|
||||
├── repair/ # Pillar 3 pipeline
|
||||
├── loop.ts # Pillar 1 + 3 — CacheFirstLoop
|
||||
├── repair/ # Pillar 2 pipeline
|
||||
│ ├── index.ts
|
||||
│ ├── scavenge.ts
|
||||
│ ├── flatten.ts
|
||||
@@ -172,7 +170,7 @@ src/
|
||||
│ ├── skills.ts # list + invoke SKILL.md playbooks
|
||||
│ ├── subagent.ts # spawn_subagent — flash+high by default
|
||||
│ ├── plan.ts # submit_plan (review gate)
|
||||
│ └── web.ts # web_search, web_fetch
|
||||
│ └── web.ts # web_search, web_fetch (multi-engine: Mojeek or SearXNG)
|
||||
├── mcp/ # MCP client + bridge (stdio + SSE)
|
||||
├── memory.ts # ImmutablePrefix / AppendOnlyLog / VolatileScratch
|
||||
├── project-memory.ts # REASONIX.md loader
|
||||
@@ -220,23 +218,24 @@ means editing one handler file and one registry line.
|
||||
|
||||
## Design evolution
|
||||
|
||||
- **v0.0.x** — Pillar 1 end-to-end, Pillar 3 complete, Ink TUI scaffold.
|
||||
- **v0.0.x** — Pillar 1 end-to-end, repair pipeline complete, Ink TUI scaffold.
|
||||
- **v0.1** — τ-bench numbers published, streaming polish, transcript replay.
|
||||
- **v0.2** — Self-consistency / branch-budget sampling driven by plan state.
|
||||
- **v0.3** — MCP client (stdio + SSE), session persistence.
|
||||
- **v0.4.x** — `reasonix code` with SEARCH/REPLACE edits, review/auto
|
||||
gate, background jobs, hooks.
|
||||
- **v0.5.x** — V4 model support, skills, memory, subagents, actionable
|
||||
error messages.
|
||||
- **v0.6** *(current)* —
|
||||
- **Pillar 4 cost control** (flash-first defaults, auto-compaction,
|
||||
`/pro` one-shot, failure-triggered escalation, cost badges).
|
||||
- **v0.6** —
|
||||
- **Cost control** (flash-first defaults, auto-compaction, `/pro` one-shot,
|
||||
failure-triggered escalation, cost badges).
|
||||
- `deepseek-chat` / `deepseek-reasoner` scheduled for deprecation —
|
||||
all user-facing surfaces updated to `v4-flash` / `v4-pro`.
|
||||
- `branch` + `harvest` removed from every preset; manual opt-in only.
|
||||
- Shared prompt fragments (`TUI_FORMATTING_RULES`, `NEGATIVE_CLAIM_RULE`).
|
||||
- UI refactor: App.tsx split into 6 hooks/components, slash.ts split
|
||||
into 13 per-topic modules.
|
||||
- **v0.31** *(current)* — `branch` + `harvest` features removed entirely
|
||||
(the parallel-sample selector and Pillar 2 plan-state extractor); both
|
||||
rarely paid for themselves and bloated the slash surface.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ a:hover { text-decoration: underline; }
|
||||
/* ── Doc chrome ─────────────────────────────────────────────────────────── */
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
@@ -90,18 +90,19 @@ a:hover { text-decoration: underline; }
|
||||
position: sticky; top: 0; align-self: start;
|
||||
height: 100vh; overflow-y: auto;
|
||||
border-right: 1px solid var(--bd);
|
||||
padding: 28px 18px;
|
||||
padding: 28px 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.toc h1 { font-size: 14px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
|
||||
.toc h1 { font-size: 15px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
|
||||
.toc h1 .dot { color: var(--c-brand); margin-right: 8px; }
|
||||
.toc .sub { font-size: 11px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
|
||||
.toc-section { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
|
||||
.toc .sub { font-size: 12px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
|
||||
.toc-section { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
|
||||
.toc-section:first-of-type { margin-top: 0; }
|
||||
.toc ul { list-style: none; padding: 0; margin: 0; }
|
||||
.toc li a {
|
||||
display: block; padding: 3px 8px; margin: 1px 0;
|
||||
color: var(--fg-2); font-size: 12.5px; border-radius: var(--r);
|
||||
display: block; padding: 4px 10px; margin: 1px 0;
|
||||
color: var(--fg-2); font-size: 14px; line-height: 1.4;
|
||||
border-radius: var(--r); overflow-wrap: anywhere;
|
||||
}
|
||||
.toc li a:hover { color: var(--fg-0); background: var(--bg-elev); text-decoration: none; }
|
||||
|
||||
@@ -114,7 +115,7 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
}
|
||||
.section > h2 .num { color: var(--fg-4); margin-right: 10px; font-weight: 500; }
|
||||
.section > .lede {
|
||||
color: var(--fg-2); margin: 0 0 22px; font-size: 13px; max-width: 700px; line-height: 1.6;
|
||||
color: var(--fg-2); margin: 0 0 22px; font-size: 15px; max-width: 720px; line-height: 1.6;
|
||||
}
|
||||
.subsec { margin-bottom: 22px; }
|
||||
.subsec > h3 {
|
||||
@@ -122,8 +123,8 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
margin: 24px 0 4px; letter-spacing: .04em; text-transform: uppercase;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 12px; text-transform: none; letter-spacing: 0; }
|
||||
.subsec > p { color: var(--fg-3); font-size: 12.5px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
|
||||
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 13px; text-transform: none; letter-spacing: 0; }
|
||||
.subsec > p { color: var(--fg-3); font-size: 15px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
|
||||
|
||||
/* "Mock" — a faux-window frame to display dashboard pieces inside the design doc */
|
||||
.mock {
|
||||
@@ -151,11 +152,11 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
.swatch .chip { width: 22px; height: 22px; border-radius: var(--r); flex-shrink: 0; border: 1px solid rgba(255,255,255,.04); }
|
||||
.swatch .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.swatch .name { color: var(--fg-1); font-size: 11.5px; }
|
||||
.swatch .hex { color: var(--fg-3); font-size: 10.5px; }
|
||||
.swatch .hex { color: var(--fg-3); font-size: 11.5px; }
|
||||
|
||||
.scale-row { display: flex; align-items: baseline; gap: 16px; padding: 6px 0; border-bottom: 1px dashed #181b22; }
|
||||
.scale-row:last-child { border-bottom: none; }
|
||||
.scale-row .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); width: 70px; flex-shrink: 0; }
|
||||
.scale-row .lbl { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-3); width: 76px; flex-shrink: 0; }
|
||||
.scale-row .ex { color: var(--fg-1); }
|
||||
|
||||
.glyph-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
|
||||
|
||||
+1
-1
@@ -403,7 +403,7 @@ npx reasonix diff \
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cli">
|
||||
<section id="cli" class="cli">
|
||||
<div class="container">
|
||||
<h2 class="section-title" data-i18n="cli.title">CLI at a glance</h2>
|
||||
<pre class="code"><code>npx reasonix code [path] <span class="hash"># <span data-i18n="cli.code">coding mode scoped to path</span></span>
|
||||
|
||||
Generated
+562
-450
File diff suppressed because it is too large.
Load diff
+7
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.27.2",
|
||||
"version": "0.36.2",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -72,11 +72,12 @@
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"ink": "^7.0.2",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-reconciler": "^0.29.2",
|
||||
"react": "^19.2.6",
|
||||
"string-width": "^7.2.0",
|
||||
"yoga-layout": "^3.2.1",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -85,14 +86,12 @@
|
||||
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-reconciler": "^0.28.9",
|
||||
"@types/react": "^19.2.14",
|
||||
"@vitest/coverage-v8": "^2.1.5",
|
||||
"esbuild": "^0.21.5",
|
||||
"highlight.js": "^11.10.0",
|
||||
"htm": "^3.1.1",
|
||||
"ink": "^5.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"preact": "^10.22.0",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/** @url mentions — async sibling of @path. Fetches each URL once and inlines under "Referenced URLs". */
|
||||
|
||||
/** Trailing punctuation stripped separately — URLs legitimately contain `,` `.` `)` in query strings. */
|
||||
export const AT_URL_PATTERN = /(?<=^|\s)@(https?:\/\/\S+)/g;
|
||||
|
||||
/** Default cap on inlined URL body (chars). */
|
||||
export const DEFAULT_AT_URL_MAX_CHARS = 32_000;
|
||||
|
||||
export interface AtUrlExpansion {
|
||||
/** The raw `@url` token as it appeared in the text. */
|
||||
token: string;
|
||||
/** Absolute URL (after trailing-punctuation strip). */
|
||||
url: string;
|
||||
/** True if content was inlined. False = skipped (reason in `skip`). */
|
||||
ok: boolean;
|
||||
/** Page title when extractable from `<title>`. */
|
||||
title?: string;
|
||||
/** Char count of the (post-truncation) inlined body. */
|
||||
chars?: number;
|
||||
/** True iff the original page exceeded `maxChars` and was clipped. */
|
||||
truncated?: boolean;
|
||||
/** Why the mention was skipped — set when ok=false. */
|
||||
skip?: "fetch-error" | "non-text" | "timeout" | "blocked";
|
||||
/** Free-form error message attached to skip outcomes. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AtUrlOptions {
|
||||
/** Max chars of inlined body per URL. */
|
||||
maxChars?: number;
|
||||
/** Per-URL fetch timeout in ms. */
|
||||
timeoutMs?: number;
|
||||
fetcher?: (
|
||||
url: string,
|
||||
opts: { maxChars?: number; timeoutMs?: number; signal?: AbortSignal },
|
||||
) => Promise<{ url: string; title?: string; text: string; truncated: boolean }>;
|
||||
cache?: Map<string, AtUrlExpansion & { body?: string }>;
|
||||
/** Forward Esc/abort to the fetcher. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function expandAtUrls(
|
||||
text: string,
|
||||
opts: AtUrlOptions = {},
|
||||
): Promise<{ text: string; expansions: AtUrlExpansion[] }> {
|
||||
const maxChars = opts.maxChars ?? DEFAULT_AT_URL_MAX_CHARS;
|
||||
const fetcher = opts.fetcher;
|
||||
if (!fetcher) {
|
||||
throw new Error("expandAtUrls: fetcher option is required (wire src/tools/web.ts:webFetch)");
|
||||
}
|
||||
|
||||
const seen = new Map<string, AtUrlExpansion>();
|
||||
const bodies = new Map<string, string>();
|
||||
const order: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(AT_URL_PATTERN)) {
|
||||
const rawUrl = match[1] ?? "";
|
||||
const url = stripUrlTail(rawUrl);
|
||||
if (!url) continue;
|
||||
if (seen.has(url)) continue;
|
||||
|
||||
const cached = opts.cache?.get(url);
|
||||
if (cached) {
|
||||
seen.set(url, cached);
|
||||
if (cached.body) bodies.set(url, cached.body);
|
||||
order.push(url);
|
||||
continue;
|
||||
}
|
||||
|
||||
let expansion: AtUrlExpansion;
|
||||
let body = "";
|
||||
try {
|
||||
const page = await fetcher(url, {
|
||||
maxChars,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
signal: opts.signal,
|
||||
});
|
||||
body = page.text;
|
||||
expansion = {
|
||||
token: `@${url}`,
|
||||
url,
|
||||
ok: true,
|
||||
title: page.title,
|
||||
chars: body.length,
|
||||
truncated: page.truncated,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? String(err);
|
||||
let skip: AtUrlExpansion["skip"] = "fetch-error";
|
||||
if (/aborted|timeout/i.test(message)) skip = "timeout";
|
||||
else if (/40\d|forbidden|access denied|captcha/i.test(message)) skip = "blocked";
|
||||
expansion = {
|
||||
token: `@${url}`,
|
||||
url,
|
||||
ok: false,
|
||||
skip,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
seen.set(url, expansion);
|
||||
if (body) bodies.set(url, body);
|
||||
if (opts.cache) opts.cache.set(url, { ...expansion, body });
|
||||
order.push(url);
|
||||
}
|
||||
|
||||
if (seen.size === 0) return { text, expansions: [] };
|
||||
|
||||
const expansions = order.map((u) => seen.get(u)!).filter(Boolean);
|
||||
const blocks: string[] = [];
|
||||
for (const ex of expansions) {
|
||||
if (ex.ok) {
|
||||
const titleAttr = ex.title ? ` title="${escapeAttr(ex.title)}"` : "";
|
||||
const truncTag = ex.truncated ? ' truncated="true"' : "";
|
||||
const body = bodies.get(ex.url) ?? "";
|
||||
blocks.push(`<url href="${ex.url}"${titleAttr}${truncTag}>\n${body}\n</url>`);
|
||||
} else {
|
||||
const reasonAttr = ex.skip ?? "fetch-error";
|
||||
blocks.push(`<url href="${ex.url}" skipped="${reasonAttr}" />`);
|
||||
}
|
||||
}
|
||||
const augmented = `${text}\n\n[Referenced URLs]\n${blocks.join("\n\n")}`;
|
||||
return { text: augmented, expansions };
|
||||
}
|
||||
|
||||
/** Only strips `.,;:!?` and unmatched close-brackets — internal path / query punctuation preserved. */
|
||||
export function stripUrlTail(raw: string): string {
|
||||
let s = raw;
|
||||
while (s.length > 0) {
|
||||
const last = s[s.length - 1]!;
|
||||
if (".,;:!?".includes(last)) {
|
||||
s = s.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
if (")]}>".includes(last)) {
|
||||
const open = ({ ")": "(", "]": "[", "}": "{", ">": "<" } as const)[
|
||||
last as ")" | "]" | "}" | ">"
|
||||
];
|
||||
if (!s.includes(open)) {
|
||||
s = s.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/"/g, """)
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
+323
-210
@@ -13,6 +13,9 @@ import {
|
||||
/** Caps match tool-result dispatch truncation (0.5.2). */
|
||||
export const DEFAULT_AT_MENTION_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/** Cap on entries returned for a `@<dir>` listing. ~200 paths × ~50 chars ≈ 10 KB — fits inside DEFAULT_AT_MENTION_MAX_BYTES with room for the rest of the prompt. */
|
||||
export const DEFAULT_AT_DIR_MAX_ENTRIES = 200;
|
||||
|
||||
/** Universally-uninteresting build / VCS dirs. Framework-specific dirs (Pods, target, …) live in .gitignore. */
|
||||
export const DEFAULT_PICKER_IGNORE_DIRS: readonly string[] = [
|
||||
"node_modules",
|
||||
@@ -91,6 +94,19 @@ export function listFilesWithStatsSync(root: string, opts: ListFilesOptions = {}
|
||||
/* stat failed (permission / EAGAIN) — keep the entry with mtime=0 */
|
||||
}
|
||||
out.push({ path: relPath, mtimeMs });
|
||||
} else if (ent.isSymbolicLink()) {
|
||||
// Dirent.isFile() returns false for symlinks even when they point at
|
||||
// regular files — stat the target to recover them. Symlinks-to-dirs
|
||||
// are not followed (cycle risk).
|
||||
let target: ReturnType<typeof statSync> | null = null;
|
||||
try {
|
||||
target = statSync(absPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!target.isFile()) continue;
|
||||
if (ignoredByLayers(effectiveLayers, absPath, false)) continue;
|
||||
out.push({ path: relPath, mtimeMs: target.mtimeMs });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -104,18 +120,65 @@ export async function listFilesWithStatsAsync(
|
||||
root: string,
|
||||
opts: ListFilesOptions = {},
|
||||
): Promise<FileWithStats[]> {
|
||||
const maxResults = Math.max(1, opts.maxResults ?? 2000);
|
||||
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
|
||||
const rootAbs = resolve(root);
|
||||
const respectGi = opts.respectGitignore !== false;
|
||||
const out: FileWithStats[] = [];
|
||||
const maxResults = Math.max(1, opts.maxResults ?? 2000);
|
||||
await walkFilesStream(root, {
|
||||
...opts,
|
||||
onEntry: (e) => {
|
||||
out.push(e);
|
||||
return out.length < maxResults;
|
||||
},
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface StreamWalkOptions {
|
||||
ignoreDirs?: readonly string[];
|
||||
respectGitignore?: boolean;
|
||||
signal?: AbortSignal;
|
||||
/** Called per file entry. Return false to halt the walk. */
|
||||
onEntry: (entry: FileWithStats) => boolean | undefined;
|
||||
/** Called periodically with the running file-count. */
|
||||
onProgress?: (scanned: number) => void;
|
||||
/** Default 100ms — minimum gap between onProgress calls. */
|
||||
progressIntervalMs?: number;
|
||||
}
|
||||
|
||||
/** Cancelable, streaming walker. Drives `listFilesWithStatsAsync` and the picker's search-mode walk. */
|
||||
export async function walkFilesStream(
|
||||
root: string,
|
||||
opts: StreamWalkOptions,
|
||||
): Promise<{ scanned: number; cancelled: boolean }> {
|
||||
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
|
||||
const respectGi = opts.respectGitignore !== false;
|
||||
const rootAbs = resolve(root);
|
||||
const progressGap = Math.max(0, opts.progressIntervalMs ?? 100);
|
||||
let scanned = 0;
|
||||
let halted = false;
|
||||
let lastProgress = 0;
|
||||
|
||||
const reportProgress = (force: boolean) => {
|
||||
if (!opts.onProgress) return;
|
||||
const now = Date.now();
|
||||
if (force || now - lastProgress >= progressGap) {
|
||||
lastProgress = now;
|
||||
opts.onProgress(scanned);
|
||||
}
|
||||
};
|
||||
|
||||
const emit = (entry: FileWithStats) => {
|
||||
scanned++;
|
||||
if (halted) return;
|
||||
if (opts.onEntry(entry) === false) halted = true;
|
||||
reportProgress(false);
|
||||
};
|
||||
|
||||
const walk = async (
|
||||
dirAbs: string,
|
||||
dirRel: string,
|
||||
layers: readonly GitignoreLayer[],
|
||||
): Promise<void> => {
|
||||
if (out.length >= maxResults) return;
|
||||
if (halted || opts.signal?.aborted) return;
|
||||
let effectiveLayers = layers;
|
||||
if (respectGi) {
|
||||
const ig = await loadGitignoreAt(dirAbs);
|
||||
@@ -128,67 +191,172 @@ export async function listFilesWithStatsAsync(
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// Stats batched per directory to amortize syscall overhead. Recursion stays
|
||||
// sequential so the merged DFS order matches the sync walker's contract.
|
||||
const fileEnts: Dirent[] = [];
|
||||
for (const ent of entries) {
|
||||
if (out.length >= maxResults) break;
|
||||
const relPath = dirRel ? `${dirRel}/${ent.name}` : ent.name;
|
||||
if (halted || opts.signal?.aborted) break;
|
||||
const absPath = join(dirAbs, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
if (ent.name.startsWith(".") || ignoreDirs.has(ent.name)) continue;
|
||||
if (ignoredByLayers(effectiveLayers, absPath, true)) continue;
|
||||
// Drain pending file stats from THIS directory before
|
||||
// descending so the output order stays DFS-alphabetical.
|
||||
if (fileEnts.length > 0) {
|
||||
await statBatch(fileEnts, dirAbs, dirRel, out, maxResults, effectiveLayers);
|
||||
await flushFiles(fileEnts, dirAbs, dirRel, effectiveLayers, emit);
|
||||
fileEnts.length = 0;
|
||||
if (out.length >= maxResults) return;
|
||||
if (halted || opts.signal?.aborted) return;
|
||||
}
|
||||
await walk(absPath, relPath, effectiveLayers);
|
||||
} else if (ent.isFile()) {
|
||||
await walk(absPath, dirRel ? `${dirRel}/${ent.name}` : ent.name, effectiveLayers);
|
||||
} else if (ent.isFile() || ent.isSymbolicLink()) {
|
||||
fileEnts.push(ent);
|
||||
}
|
||||
}
|
||||
if (fileEnts.length > 0 && out.length < maxResults) {
|
||||
await statBatch(fileEnts, dirAbs, dirRel, out, maxResults, effectiveLayers);
|
||||
if (fileEnts.length > 0 && !halted && !opts.signal?.aborted) {
|
||||
await flushFiles(fileEnts, dirAbs, dirRel, effectiveLayers, emit);
|
||||
}
|
||||
};
|
||||
|
||||
await walk(rootAbs, "", []);
|
||||
return out;
|
||||
reportProgress(true);
|
||||
return { scanned, cancelled: !!opts.signal?.aborted };
|
||||
}
|
||||
|
||||
async function statBatch(
|
||||
async function flushFiles(
|
||||
ents: readonly Dirent[],
|
||||
dirAbs: string,
|
||||
dirRel: string,
|
||||
out: FileWithStats[],
|
||||
maxResults: number,
|
||||
layers: readonly GitignoreLayer[],
|
||||
emit: (e: FileWithStats) => void,
|
||||
): Promise<void> {
|
||||
const accepted: Dirent[] = [];
|
||||
for (const e of ents) {
|
||||
if (out.length + accepted.length >= maxResults) break;
|
||||
if (ignoredByLayers(layers, join(dirAbs, e.name), false)) continue;
|
||||
accepted.push(e);
|
||||
}
|
||||
const accepted = ents.filter((e) => !ignoredByLayers(layers, join(dirAbs, e.name), false));
|
||||
const stats = await Promise.all(
|
||||
accepted.map((e) =>
|
||||
stat(join(dirAbs, e.name))
|
||||
.then((s) => s.mtimeMs)
|
||||
.catch(() => 0),
|
||||
.then((s) => ({ mtimeMs: s.mtimeMs, isFile: s.isFile() }))
|
||||
.catch(() => null),
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const ent = accepted[i]!;
|
||||
out.push({
|
||||
const s = stats[i];
|
||||
if (ent.isSymbolicLink() && (!s || !s.isFile)) continue;
|
||||
emit({
|
||||
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
|
||||
mtimeMs: stats[i] ?? 0,
|
||||
mtimeMs: s?.mtimeMs ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
/** Relative-to-root path (forward slashes). For dirs, no trailing slash. */
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
/** 0 for directories (no stat), real mtime for files. */
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface ListDirectoryOptions {
|
||||
ignoreDirs?: readonly string[];
|
||||
respectGitignore?: boolean;
|
||||
}
|
||||
|
||||
/** One-level browse for the @-picker. Folders first then files, alpha within each group. Resolves outside-root to []. */
|
||||
export async function listDirectory(
|
||||
root: string,
|
||||
relDir: string,
|
||||
opts: ListDirectoryOptions = {},
|
||||
): Promise<DirEntry[]> {
|
||||
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
|
||||
const respectGi = opts.respectGitignore !== false;
|
||||
const rootAbs = resolve(root);
|
||||
const dirAbs = resolve(rootAbs, relDir);
|
||||
const rel = relative(rootAbs, dirAbs);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) return [];
|
||||
|
||||
const layers: GitignoreLayer[] = [];
|
||||
if (respectGi) {
|
||||
const segs = rel ? rel.split(/[\\/]/) : [];
|
||||
let cursor = rootAbs;
|
||||
const ig = await loadGitignoreAt(cursor);
|
||||
if (ig) layers.push({ dirAbs: cursor, ig });
|
||||
for (const seg of segs) {
|
||||
cursor = join(cursor, seg);
|
||||
const igSeg = await loadGitignoreAt(cursor);
|
||||
if (igSeg) layers.push({ dirAbs: cursor, ig: igSeg });
|
||||
}
|
||||
}
|
||||
|
||||
let raw: Dirent[];
|
||||
try {
|
||||
raw = await readdir(dirAbs, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const dirRel = rel.split(/[\\/]/).join("/");
|
||||
const dirs: DirEntry[] = [];
|
||||
const files: Dirent[] = [];
|
||||
for (const ent of raw) {
|
||||
const absPath = join(dirAbs, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
if (ent.name.startsWith(".") || ignoreDirs.has(ent.name)) continue;
|
||||
if (ignoredByLayers(layers, absPath, true)) continue;
|
||||
dirs.push({
|
||||
name: ent.name,
|
||||
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
|
||||
isDir: true,
|
||||
mtimeMs: 0,
|
||||
});
|
||||
} else if (ent.isFile() || ent.isSymbolicLink()) {
|
||||
if (ignoredByLayers(layers, absPath, false)) continue;
|
||||
files.push(ent);
|
||||
}
|
||||
}
|
||||
const stats = await Promise.all(
|
||||
files.map((e) =>
|
||||
stat(join(dirAbs, e.name))
|
||||
.then((s) => ({ mtimeMs: s.mtimeMs, isFile: s.isFile() }))
|
||||
.catch(() => null),
|
||||
),
|
||||
);
|
||||
const fileEntries: DirEntry[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const ent = files[i]!;
|
||||
const s = stats[i];
|
||||
if (ent.isSymbolicLink() && (!s || !s.isFile)) continue;
|
||||
fileEntries.push({
|
||||
name: ent.name,
|
||||
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
|
||||
isDir: false,
|
||||
mtimeMs: s?.mtimeMs ?? 0,
|
||||
});
|
||||
}
|
||||
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
fileEntries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...dirs, ...fileEntries];
|
||||
}
|
||||
|
||||
export interface ParsedAtQuery {
|
||||
/** Directory portion (rel from root, no trailing slash). Empty = root. */
|
||||
dir: string;
|
||||
/** Filter portion — chars after the last slash. Empty if query ended in `/`. */
|
||||
filter: string;
|
||||
/** True if the query ended in `/` — caller knows to browse `dir`. */
|
||||
trailingSlash: boolean;
|
||||
}
|
||||
|
||||
/** Split `src/auth/log` → `{dir: "src/auth", filter: "log"}`; trailing slash sets `trailingSlash` and clears filter. */
|
||||
export function parseAtQuery(query: string): ParsedAtQuery {
|
||||
const normalized = query.replace(/\\/g, "/");
|
||||
const trailingSlash = normalized.endsWith("/");
|
||||
const trimmed = trailingSlash ? normalized.slice(0, -1) : normalized;
|
||||
const lastSlash = trimmed.lastIndexOf("/");
|
||||
if (trailingSlash) return { dir: trimmed, filter: "", trailingSlash: true };
|
||||
if (lastSlash < 0) return { dir: "", filter: trimmed, trailingSlash: false };
|
||||
return {
|
||||
dir: trimmed.slice(0, lastSlash),
|
||||
filter: trimmed.slice(lastSlash + 1),
|
||||
trailingSlash: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Trailing-token only, anchored at end-of-input — distinct from `AT_MENTION_PATTERN` which scans all. */
|
||||
export const AT_PICKER_PREFIX = /(?:^|\s)@([a-zA-Z0-9_./\\-]*)$/;
|
||||
|
||||
@@ -251,15 +419,25 @@ export function rankPickerCandidates(
|
||||
for (const e of entries) {
|
||||
const lower = e.path.toLowerCase();
|
||||
const hit = lower.indexOf(needle);
|
||||
if (hit < 0) continue;
|
||||
const slash = lower.lastIndexOf("/");
|
||||
const base = slash >= 0 ? lower.slice(slash + 1) : lower;
|
||||
let score = 2;
|
||||
if (base.startsWith(needle)) score = 0;
|
||||
else if (lower.startsWith(needle)) score = 1;
|
||||
if (hit >= 0) {
|
||||
const slash = lower.lastIndexOf("/");
|
||||
const base = slash >= 0 ? lower.slice(slash + 1) : lower;
|
||||
let cls = 2;
|
||||
if (base.startsWith(needle)) cls = 0;
|
||||
else if (lower.startsWith(needle)) cls = 1;
|
||||
scored.push({
|
||||
path: e.path,
|
||||
score: cls * 10_000 + Math.min(hit, 9999),
|
||||
mtimeMs: e.mtimeMs,
|
||||
recent: recent.has(e.path),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const fuzzy = fuzzySubseqScore(needle, lower);
|
||||
if (fuzzy === null) continue;
|
||||
scored.push({
|
||||
path: e.path,
|
||||
score: score * 10_000 + hit,
|
||||
score: 30_000 + fuzzy,
|
||||
mtimeMs: e.mtimeMs,
|
||||
recent: recent.has(e.path),
|
||||
});
|
||||
@@ -273,6 +451,29 @@ export function rankPickerCandidates(
|
||||
return scored.slice(0, limit).map((s) => s.path);
|
||||
}
|
||||
|
||||
function fuzzySubseqScore(needle: string, target: string): number | null {
|
||||
if (needle.length === 0) return 0;
|
||||
const slashIdx = target.lastIndexOf("/");
|
||||
const basenameStart = slashIdx >= 0 ? slashIdx + 1 : 0;
|
||||
let qi = 0;
|
||||
let lastMatchIdx = -2;
|
||||
let consecutive = 0;
|
||||
let basenameMatches = 0;
|
||||
let totalGap = 0;
|
||||
for (let ti = 0; ti < target.length && qi < needle.length; ti++) {
|
||||
if (target[ti] !== needle[qi]) continue;
|
||||
if (ti === lastMatchIdx + 1) consecutive++;
|
||||
else if (lastMatchIdx >= 0) totalGap += ti - lastMatchIdx - 1;
|
||||
if (ti >= basenameStart) basenameMatches++;
|
||||
lastMatchIdx = ti;
|
||||
qi++;
|
||||
}
|
||||
if (qi < needle.length) return null;
|
||||
const quality = Math.max(0, totalGap - consecutive * 10 - basenameMatches * 5);
|
||||
const lengthPenalty = Math.floor(target.length / 4);
|
||||
return quality + lengthPenalty;
|
||||
}
|
||||
|
||||
/** Word-boundary anchor rejects `@` embedded in emails / social handles; trailing `.` stripped before lookup. */
|
||||
export const AT_MENTION_PATTERN = /(?<=^|\s)@([a-zA-Z0-9_./\\-]+)/g;
|
||||
|
||||
@@ -283,8 +484,14 @@ export interface AtMentionExpansion {
|
||||
path: string;
|
||||
/** True if the content was inlined. False = skipped (reason in `skip`). */
|
||||
ok: boolean;
|
||||
/** Bytes read (only for ok=true). */
|
||||
/** Bytes read (only for ok=true and isDirectory=false). */
|
||||
bytes?: number;
|
||||
/** True when the mention resolved to a directory (ok=true). Block uses `<directory>` instead of `<file>`. */
|
||||
isDirectory?: boolean;
|
||||
/** Number of files listed when isDirectory=true. */
|
||||
entries?: number;
|
||||
/** True iff the directory listing was clipped at maxDirEntries. */
|
||||
truncated?: boolean;
|
||||
/** Why the mention was skipped. Set when ok=false. */
|
||||
skip?: "missing" | "not-file" | "too-large" | "escape" | "read-error";
|
||||
}
|
||||
@@ -292,9 +499,19 @@ export interface AtMentionExpansion {
|
||||
export interface AtMentionOptions {
|
||||
/** Max file size in bytes before a mention is skipped. */
|
||||
maxBytes?: number;
|
||||
/** Cap on entries returned for a `@<dir>` listing. Default {@link DEFAULT_AT_DIR_MAX_ENTRIES}. */
|
||||
maxDirEntries?: number;
|
||||
fs?: {
|
||||
exists: (path: string) => boolean;
|
||||
isFile: (path: string) => boolean;
|
||||
/** Optional — when omitted, directories are skipped as `not-file`. */
|
||||
isDir?: (path: string) => boolean;
|
||||
/** Optional — receives the directory's absolute path and the project root, returns relative paths and a truncated flag. */
|
||||
listDir?: (
|
||||
dirAbs: string,
|
||||
root: string,
|
||||
max: number,
|
||||
) => { files: string[]; truncated: boolean };
|
||||
size: (path: string) => number;
|
||||
read: (path: string) => string;
|
||||
};
|
||||
@@ -306,22 +523,28 @@ export function expandAtMentions(
|
||||
opts: AtMentionOptions = {},
|
||||
): { text: string; expansions: AtMentionExpansion[] } {
|
||||
const maxBytes = opts.maxBytes ?? DEFAULT_AT_MENTION_MAX_BYTES;
|
||||
const maxDirEntries = Math.max(1, opts.maxDirEntries ?? DEFAULT_AT_DIR_MAX_ENTRIES);
|
||||
const fs = opts.fs ?? defaultFs;
|
||||
const root = resolve(rootDir);
|
||||
// De-dupe by token so `@file.ts` referenced twice inlines once.
|
||||
const seen = new Map<string, AtMentionExpansion>();
|
||||
const expansions: AtMentionExpansion[] = [];
|
||||
const dirListings = new Map<string, string[]>();
|
||||
|
||||
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);
|
||||
// Strip a single trailing slash so `@docs/` and `@docs` resolve identically.
|
||||
if (cleaned.endsWith("/") || cleaned.endsWith("\\")) cleaned = cleaned.slice(0, -1);
|
||||
if (!cleaned) continue;
|
||||
const token = `@${cleaned}`;
|
||||
if (seen.has(token)) continue;
|
||||
|
||||
const expansion = resolveMention(cleaned, root, maxBytes, fs);
|
||||
const expansion = resolveMention(cleaned, root, maxBytes, maxDirEntries, fs, dirListings);
|
||||
seen.set(token, expansion);
|
||||
expansions.push(expansion);
|
||||
}
|
||||
@@ -333,7 +556,14 @@ export function expandAtMentions(
|
||||
// both what's here and what's missing.
|
||||
const blocks: string[] = [];
|
||||
for (const ex of expansions) {
|
||||
if (ex.ok) {
|
||||
if (ex.ok && ex.isDirectory) {
|
||||
const files = dirListings.get(ex.path) ?? [];
|
||||
const truncAttr = ex.truncated ? ' truncated="true"' : "";
|
||||
const body = files.length > 0 ? `\n${files.join("\n")}\n` : "\n";
|
||||
blocks.push(
|
||||
`<directory path="${ex.path}" entries="${ex.entries ?? files.length}"${truncAttr}>${body}</directory>`,
|
||||
);
|
||||
} else if (ex.ok) {
|
||||
const content = readSafe(root, ex.path, fs);
|
||||
blocks.push(`<file path="${ex.path}">\n${content}\n</file>`);
|
||||
} else {
|
||||
@@ -348,7 +578,9 @@ function resolveMention(
|
||||
rawPath: string,
|
||||
root: string,
|
||||
maxBytes: number,
|
||||
maxDirEntries: number,
|
||||
fs: NonNullable<AtMentionOptions["fs"]>,
|
||||
dirListings: Map<string, string[]>,
|
||||
): AtMentionExpansion {
|
||||
// Reject absolute paths — `@/etc/passwd` should not inline.
|
||||
if (isAbsolute(rawPath)) {
|
||||
@@ -363,14 +595,28 @@ function resolveMention(
|
||||
if (!fs.exists(resolved)) {
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "missing" };
|
||||
}
|
||||
if (!fs.isFile(resolved)) {
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "not-file" };
|
||||
if (fs.isFile(resolved)) {
|
||||
const size = fs.size(resolved);
|
||||
if (size > maxBytes) {
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "too-large", bytes: size };
|
||||
}
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: true, bytes: size };
|
||||
}
|
||||
const size = fs.size(resolved);
|
||||
if (size > maxBytes) {
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "too-large", bytes: size };
|
||||
// Not a file — try the directory branch. listDir is optional; without it,
|
||||
// fall back to the legacy not-file skip so test fixtures don't break.
|
||||
if (fs.isDir?.(resolved) && fs.listDir) {
|
||||
const { files, truncated } = fs.listDir(resolved, root, maxDirEntries);
|
||||
dirListings.set(rawPath, files);
|
||||
return {
|
||||
token: `@${rawPath}`,
|
||||
path: rawPath,
|
||||
ok: true,
|
||||
isDirectory: true,
|
||||
entries: files.length,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: true, bytes: size };
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "not-file" };
|
||||
}
|
||||
|
||||
function readSafe(root: string, rawPath: string, fs: NonNullable<AtMentionOptions["fs"]>): string {
|
||||
@@ -391,6 +637,27 @@ const defaultFs: NonNullable<AtMentionOptions["fs"]> = {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
isDir: (p) => {
|
||||
try {
|
||||
return statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
listDir: (dirAbs, root, max) => {
|
||||
// Walk from project root and filter to entries under dirAbs so the
|
||||
// listing inherits the parent .gitignore layers. Walking dirAbs alone
|
||||
// would miss the project-root rules above it.
|
||||
const dirRel = relative(root, dirAbs).split(/[\\/]/).join("/");
|
||||
const walkCap = Math.max(max * 4, 5000);
|
||||
const all = listFilesSync(root, { maxResults: walkCap });
|
||||
const prefix = dirRel ? `${dirRel}/` : "";
|
||||
const filtered = dirRel ? all.filter((f) => f === dirRel || f.startsWith(prefix)) : all;
|
||||
return {
|
||||
files: filtered.slice(0, max),
|
||||
truncated: filtered.length > max,
|
||||
};
|
||||
},
|
||||
size: (p) => {
|
||||
try {
|
||||
return statSync(p).size;
|
||||
@@ -401,164 +668,10 @@ const defaultFs: NonNullable<AtMentionOptions["fs"]> = {
|
||||
read: (p) => readFileSync(p, "utf8"),
|
||||
};
|
||||
|
||||
// @url mentions — async sibling of @path. Matches `@http(s)://...` after a
|
||||
// word boundary, fetches each URL once per session (in-memory cache), and
|
||||
// appends a "Referenced URLs" block under the prompt the model sees. Uses
|
||||
// the same web-fetch + HTML-strip pipeline as the model's `web_fetch` tool
|
||||
// so a `@url` reference and a model-issued fetch produce identical content.
|
||||
|
||||
/** Trailing punctuation stripped separately — URLs legitimately contain `,` `.` `)` in query strings. */
|
||||
export const AT_URL_PATTERN = /(?<=^|\s)@(https?:\/\/\S+)/g;
|
||||
|
||||
/** Default cap on inlined URL body (chars). Matches DEFAULT_AT_MENTION_MAX_BYTES order-of-magnitude. */
|
||||
export const DEFAULT_AT_URL_MAX_CHARS = 32_000;
|
||||
|
||||
export interface AtUrlExpansion {
|
||||
/** The raw `@url` token as it appeared in the text. */
|
||||
token: string;
|
||||
/** Absolute URL (after trailing-punctuation strip). */
|
||||
url: string;
|
||||
/** True if content was inlined. False = skipped (reason in `skip`). */
|
||||
ok: boolean;
|
||||
/** Page title when extractable from `<title>`. */
|
||||
title?: string;
|
||||
/** Char count of the (post-truncation) inlined body. */
|
||||
chars?: number;
|
||||
/** True iff the original page exceeded `maxChars` and was clipped. */
|
||||
truncated?: boolean;
|
||||
/** Why the mention was skipped — set when ok=false. */
|
||||
skip?: "fetch-error" | "non-text" | "timeout" | "blocked";
|
||||
/** Free-form error message attached to skip outcomes. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AtUrlOptions {
|
||||
/** Max chars of inlined body per URL. Default DEFAULT_AT_URL_MAX_CHARS. */
|
||||
maxChars?: number;
|
||||
/** Per-URL fetch timeout in ms. */
|
||||
timeoutMs?: number;
|
||||
fetcher?: (
|
||||
url: string,
|
||||
opts: { maxChars?: number; timeoutMs?: number; signal?: AbortSignal },
|
||||
) => Promise<{ url: string; title?: string; text: string; truncated: boolean }>;
|
||||
cache?: Map<string, AtUrlExpansion & { body?: string }>;
|
||||
/** Forward Esc/abort to the fetcher. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function expandAtUrls(
|
||||
text: string,
|
||||
opts: AtUrlOptions = {},
|
||||
): Promise<{ text: string; expansions: AtUrlExpansion[] }> {
|
||||
const maxChars = opts.maxChars ?? DEFAULT_AT_URL_MAX_CHARS;
|
||||
const fetcher = opts.fetcher;
|
||||
if (!fetcher) {
|
||||
throw new Error("expandAtUrls: fetcher option is required (wire src/tools/web.ts:webFetch)");
|
||||
}
|
||||
|
||||
// De-dupe by URL so the same `@https://x.com` referenced twice fetches once.
|
||||
const seen = new Map<string, AtUrlExpansion>();
|
||||
const bodies = new Map<string, string>();
|
||||
const order: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(AT_URL_PATTERN)) {
|
||||
const rawUrl = match[1] ?? "";
|
||||
const url = stripUrlTail(rawUrl);
|
||||
if (!url) continue;
|
||||
if (seen.has(url)) continue;
|
||||
|
||||
const cached = opts.cache?.get(url);
|
||||
if (cached) {
|
||||
seen.set(url, cached);
|
||||
if (cached.body) bodies.set(url, cached.body);
|
||||
order.push(url);
|
||||
continue;
|
||||
}
|
||||
|
||||
let expansion: AtUrlExpansion;
|
||||
let body = "";
|
||||
try {
|
||||
const page = await fetcher(url, {
|
||||
maxChars,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
signal: opts.signal,
|
||||
});
|
||||
body = page.text;
|
||||
expansion = {
|
||||
token: `@${url}`,
|
||||
url,
|
||||
ok: true,
|
||||
title: page.title,
|
||||
chars: body.length,
|
||||
truncated: page.truncated,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? String(err);
|
||||
// Tag a few common shapes so the UI can hint at causes.
|
||||
let skip: AtUrlExpansion["skip"] = "fetch-error";
|
||||
if (/aborted|timeout/i.test(message)) skip = "timeout";
|
||||
else if (/40\d|forbidden|access denied|captcha/i.test(message)) skip = "blocked";
|
||||
expansion = {
|
||||
token: `@${url}`,
|
||||
url,
|
||||
ok: false,
|
||||
skip,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
seen.set(url, expansion);
|
||||
if (body) bodies.set(url, body);
|
||||
if (opts.cache) opts.cache.set(url, { ...expansion, body });
|
||||
order.push(url);
|
||||
}
|
||||
|
||||
if (seen.size === 0) return { text, expansions: [] };
|
||||
|
||||
const expansions = order.map((u) => seen.get(u)!).filter(Boolean);
|
||||
const blocks: string[] = [];
|
||||
for (const ex of expansions) {
|
||||
if (ex.ok) {
|
||||
const titleAttr = ex.title ? ` title="${escapeAttr(ex.title)}"` : "";
|
||||
const truncTag = ex.truncated ? ' truncated="true"' : "";
|
||||
const body = bodies.get(ex.url) ?? "";
|
||||
blocks.push(`<url href="${ex.url}"${titleAttr}${truncTag}>\n${body}\n</url>`);
|
||||
} else {
|
||||
const reasonAttr = ex.skip ?? "fetch-error";
|
||||
blocks.push(`<url href="${ex.url}" skipped="${reasonAttr}" />`);
|
||||
}
|
||||
}
|
||||
const augmented = `${text}\n\n[Referenced URLs]\n${blocks.join("\n\n")}`;
|
||||
return { text: augmented, expansions };
|
||||
}
|
||||
|
||||
/** Only strips `.,;:!?` and unmatched close-brackets — internal path / query punctuation preserved. */
|
||||
export function stripUrlTail(raw: string): string {
|
||||
let s = raw;
|
||||
while (s.length > 0) {
|
||||
const last = s[s.length - 1]!;
|
||||
if (".,;:!?".includes(last)) {
|
||||
s = s.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
if (")]}>".includes(last)) {
|
||||
// Only strip if the matching open bracket isn't elsewhere in the
|
||||
// URL — avoids butchering legitimate `(thing)` query fragments.
|
||||
const open = ({ ")": "(", "]": "[", "}": "{", ">": "<" } as const)[
|
||||
last as ")" | "]" | "}" | ">"
|
||||
];
|
||||
if (!s.includes(open)) {
|
||||
s = s.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/"/g, """)
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
export {
|
||||
AT_URL_PATTERN,
|
||||
DEFAULT_AT_URL_MAX_CHARS,
|
||||
expandAtUrls,
|
||||
stripUrlTail,
|
||||
} from "./at-mentions-url.js";
|
||||
export type { AtUrlExpansion, AtUrlOptions } from "./at-mentions-url.js";
|
||||
+63
-20
@@ -1,10 +1,17 @@
|
||||
import { render } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { loadApiKey, readConfig, searchEnabled } from "../../config.js";
|
||||
import {
|
||||
loadApiKey,
|
||||
readConfig,
|
||||
searchEnabled,
|
||||
webSearchEndpoint,
|
||||
webSearchEngine,
|
||||
} from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import type { CacheFirstLoop } from "../../loop.js";
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { type InspectionReport, inspectMcpServer } from "../../mcp/inspect.js";
|
||||
import { preflightStdioSpec } from "../../mcp/preflight.js";
|
||||
import { type McpClientHost, bridgeMcpTools } from "../../mcp/registry.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { SseTransport } from "../../mcp/sse.js";
|
||||
@@ -21,9 +28,11 @@ import { ToolRegistry } from "../../tools.js";
|
||||
import { registerChoiceTool } from "../../tools/choice.js";
|
||||
import { registerMemoryTools } from "../../tools/memory.js";
|
||||
import { registerWebTools } from "../../tools/web.js";
|
||||
import { markPhase } from "../startup-profile.js";
|
||||
import { App } from "../ui/App.js";
|
||||
import { SessionPicker } from "../ui/SessionPicker.js";
|
||||
import { Setup } from "../ui/Setup.js";
|
||||
import { drainTtyResponses } from "../ui/drain-tty.js";
|
||||
import { KeystrokeProvider } from "../ui/keystroke-context.js";
|
||||
import { formatMcpLifecycleEvent } from "../ui/mcp-lifecycle.js";
|
||||
import { formatMcpSlowToast } from "../ui/mcp-toast.js";
|
||||
@@ -101,6 +110,7 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
|
||||
: ctx.getRequestedCount() === 1 && ctx.getMcpPrefix()
|
||||
? (ctx.getMcpPrefix() as string)
|
||||
: "";
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const transport: McpTransport =
|
||||
spec.transport === "sse"
|
||||
? new SseTransport({ url: spec.url })
|
||||
@@ -254,8 +264,6 @@ export interface ChatOptions {
|
||||
model: string;
|
||||
system: string;
|
||||
transcript?: string;
|
||||
harvest?: boolean;
|
||||
branch?: number;
|
||||
/**
|
||||
* Soft USD cap on session spend. Undefined → no cap (default).
|
||||
* The loop warns once at 80% and refuses to start a new turn at
|
||||
@@ -289,6 +297,8 @@ export interface ChatOptions {
|
||||
* then falls back to non-tool updates only).
|
||||
*/
|
||||
reregisterTools?: (rootDir: string) => void;
|
||||
/** Async tail of `/cwd` — re-probe the new dir for a semantic index. */
|
||||
reBootstrapSemantic?: (rootDir: string) => Promise<{ enabled: boolean }>;
|
||||
};
|
||||
/** Skip the session picker — assume "Resume" (backwards-compatible auto-continue). */
|
||||
forceResume?: boolean;
|
||||
@@ -300,6 +310,21 @@ export interface ChatOptions {
|
||||
* URL is visible in the status bar.
|
||||
*/
|
||||
noDashboard?: boolean;
|
||||
/**
|
||||
* Render into the terminal's alternate screen buffer. Default true —
|
||||
* alt-screen avoids the scrollback-mode resize/wrap ghost class. Pass
|
||||
* false (CLI: `--no-alt-screen`) when the chat output needs to remain
|
||||
* in shell scrollback after exit.
|
||||
*/
|
||||
altScreen?: boolean;
|
||||
/**
|
||||
* Enable DECSET 1007 (alternate-scroll) so the wheel scrolls chat on
|
||||
* web/cloud/SSH terminals — terminal translates wheel events to ↑/↓
|
||||
* key sequences in alt-screen, no full mouse tracking, native
|
||||
* drag-select + right-click unaffected. Default true. Pass false
|
||||
* (CLI: `--no-mouse`) to suppress entirely.
|
||||
*/
|
||||
mouse?: boolean;
|
||||
}
|
||||
|
||||
interface RootProps extends ChatOptions {
|
||||
@@ -387,8 +412,6 @@ function Root({
|
||||
model={appProps.model}
|
||||
system={appProps.system}
|
||||
transcript={appProps.transcript}
|
||||
harvest={appProps.harvest}
|
||||
branch={appProps.branch}
|
||||
budgetUsd={appProps.budgetUsd}
|
||||
session={activeSession}
|
||||
tools={tools}
|
||||
@@ -398,6 +421,7 @@ function Root({
|
||||
progressSink={progressSink}
|
||||
codeMode={appProps.codeMode}
|
||||
noDashboard={appProps.noDashboard}
|
||||
mouse={appProps.mouse}
|
||||
onSwitchSession={setActiveSession}
|
||||
/>
|
||||
</KeystrokeProvider>
|
||||
@@ -405,8 +429,10 @@ function Root({
|
||||
}
|
||||
|
||||
export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
markPhase("chat_command_enter");
|
||||
loadDotenv();
|
||||
const initialKey = loadApiKey();
|
||||
markPhase("config_loaded");
|
||||
|
||||
const requestedSpecs = opts.mcp ?? [];
|
||||
// Shared progress sink: the bridge's onProgress callback writes
|
||||
@@ -429,14 +455,22 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
});
|
||||
|
||||
const failedSpecs: Array<{ spec: string; reason: string }> = [];
|
||||
if (requestedSpecs.length > 0) markPhase("mcp_launch");
|
||||
for (const raw of requestedSpecs) {
|
||||
const result = await runtime.addSpec(raw);
|
||||
if (!result.ok) failedSpecs.push({ spec: raw, reason: result.reason });
|
||||
}
|
||||
if (requestedSpecs.length > 0) {
|
||||
markPhase(
|
||||
`mcp_connected_${requestedSpecs.length - failedSpecs.length}_of_${requestedSpecs.length}`,
|
||||
);
|
||||
}
|
||||
if (runtime.size() === 0 && !opts.seedTools) {
|
||||
tools = undefined;
|
||||
}
|
||||
const mcpSpecs = runtime.specs();
|
||||
// Preserve configured specs even when startup leaves them unbridged
|
||||
// so `/mcp` can still surface the configured-but-offline set.
|
||||
const mcpSpecs = [...requestedSpecs];
|
||||
const mcpServers = runtime.summaries();
|
||||
|
||||
// Register web search/fetch tools unless explicitly disabled. DDG
|
||||
@@ -444,7 +478,10 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
// a question needs info fresher than its training data.
|
||||
if (searchEnabled()) {
|
||||
if (!tools) tools = new ToolRegistry();
|
||||
registerWebTools(tools);
|
||||
registerWebTools(tools, {
|
||||
webSearchEngine: webSearchEngine(),
|
||||
webSearchEndpoint: webSearchEndpoint(),
|
||||
});
|
||||
}
|
||||
|
||||
// Memory tools — available in every session, not just code mode.
|
||||
@@ -475,17 +512,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
const showPicker =
|
||||
!opts.session && !opts.forceResume && listSessionsForWorkspace(launchWorkspace).length > 0;
|
||||
|
||||
// No startup clear, no resize listener. Earlier attempts wrote
|
||||
// various combinations of \x1b[2J / \x1b[3J / cursor-home to
|
||||
// present a 'clean canvas' on launch and to neutralize Ink's
|
||||
// eraseLines miscount on resize, but on xterm.js-based terminals
|
||||
// (VSCode integrated terminal in particular) those sequences
|
||||
// interfere with scrollback in ways that make wheel-up scroll
|
||||
// dead. Letting Ink mount directly leaves the user's previous
|
||||
// shell prompt visible above (scrolling up just works) and
|
||||
// accepts the resize-ghost / launch-noise tradeoffs as known
|
||||
// limitations — `/clear` is the manual reset.
|
||||
|
||||
markPhase("ink_render_call");
|
||||
const { waitUntilExit } = render(
|
||||
<Root
|
||||
initialKey={initialKey}
|
||||
@@ -498,12 +525,28 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
{...opts}
|
||||
session={resolvedSession}
|
||||
/>,
|
||||
// patchConsole:false — winpty/MINTTY redraw-glitch source.
|
||||
{ exitOnCtrlC: true, patchConsole: false },
|
||||
{
|
||||
exitOnCtrlC: true,
|
||||
// patchConsole:false — winpty/MINTTY redraw-glitch source.
|
||||
patchConsole: false,
|
||||
// incrementalRendering:false — Ink's diff drifts when stringWidth
|
||||
// misjudges CJK / emoji ZWJ width or when async terminal-event
|
||||
// bytes interleave mid-render, leaving residual rows. Full-frame
|
||||
// redraws cost more stdout bytes per flush but eliminate the
|
||||
// ghost class.
|
||||
incrementalRendering: false,
|
||||
// Default true — alt-screen is the only mode without scrollback-
|
||||
// reflow ghosting. `--no-alt-screen` opts back into scrollback mode
|
||||
// for users who need chat output preserved in shell history on exit.
|
||||
alternateScreen: opts.altScreen !== false,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await waitUntilExit();
|
||||
} finally {
|
||||
await runtime.closeAll();
|
||||
// Eat any pending terminal-feature-detection responses (#365) so the
|
||||
// parent shell doesn't print them as junk after exit.
|
||||
await drainTtyResponses();
|
||||
}
|
||||
}
|
||||
+47
-12
@@ -22,6 +22,7 @@ import { readFileSync } from "node:fs";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { loadEditMode, loadProjectShellAllowed, readConfig } from "../../config.js";
|
||||
import { bootstrapSemanticSearchInCodeMode } from "../../index/semantic/tool.js";
|
||||
import { detectForeignAgentPlatform } from "../../memory/project.js";
|
||||
import { sanitizeName } from "../../memory/session.js";
|
||||
import { ToolRegistry } from "../../tools.js";
|
||||
import { registerChoiceTool } from "../../tools/choice.js";
|
||||
@@ -29,7 +30,10 @@ import { registerFilesystemTools } from "../../tools/filesystem.js";
|
||||
import { JobRegistry } from "../../tools/jobs.js";
|
||||
import { registerMemoryTools } from "../../tools/memory.js";
|
||||
import { registerPlanTool } from "../../tools/plan.js";
|
||||
import { registerScaffoldTools } from "../../tools/scaffold.js";
|
||||
import { registerShellTools } from "../../tools/shell.js";
|
||||
import { registerTodoTool } from "../../tools/todo.js";
|
||||
import { markPhase } from "../startup-profile.js";
|
||||
import { chatCommand } from "./chat.js";
|
||||
|
||||
export interface CodeOptions {
|
||||
@@ -45,15 +49,6 @@ export interface CodeOptions {
|
||||
forceResume?: boolean;
|
||||
/** Skip the session picker — always wipe prior messages and start fresh. */
|
||||
forceNew?: boolean;
|
||||
/**
|
||||
* Opt into Pillar 2 harvesting (extracts a typed plan state from R1
|
||||
* reasoning via an extra V3 call). Default OFF in code mode — the
|
||||
* displayed subgoals/hypotheses/rejectedPaths have no programmatic
|
||||
* consumer, only `uncertainties.length` feeds branching, and the
|
||||
* extra V3 call adds ~10-15% per-turn cost. Users who want the
|
||||
* reasoning surfaced explicitly can pass `--harvest`.
|
||||
*/
|
||||
harvest?: boolean;
|
||||
/**
|
||||
* Soft USD spend cap. Off by default. Same semantics as `chat`:
|
||||
* warns at 80%, refuses next turn at 100%. Mid-session adjustable
|
||||
@@ -66,9 +61,14 @@ export interface CodeOptions {
|
||||
systemAppend?: string;
|
||||
/** Path to a UTF-8 text file whose contents are appended to the code system prompt. */
|
||||
systemAppendFile?: string;
|
||||
/** Default true. Pass false (CLI: `--no-alt-screen`) to keep chat output in shell scrollback. */
|
||||
altScreen?: boolean;
|
||||
/** Default true. Pass false (CLI: `--no-mouse`) to keep terminal-native drag-select unmodified. */
|
||||
mouse?: boolean;
|
||||
}
|
||||
|
||||
export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
markPhase("code_command_enter");
|
||||
const { codeSystemPrompt } = await import("../../code/prompt.js");
|
||||
const rootDir = resolve(opts.dir ?? process.cwd());
|
||||
// Per-directory session so switching projects doesn't mix histories.
|
||||
@@ -113,6 +113,16 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
// per-project memory store; the global scope is shared across runs.
|
||||
registerMemoryTools(tools, { projectRoot: root });
|
||||
};
|
||||
// Async tail to `registerRootedTools`. Kept separate because the FS /
|
||||
// shell / memory re-registration above is sync and must happen before
|
||||
// the next tool dispatch, while semantic-index probing reads disk and
|
||||
// can race ahead in the background. On `/cwd`, App.tsx fires this
|
||||
// after the sync swap and surfaces the result via postInfo.
|
||||
const reBootstrapSemantic = async (root: string): Promise<{ enabled: boolean }> => {
|
||||
const result = await bootstrapSemanticSearchInCodeMode(tools, root);
|
||||
if (!result.enabled) tools.unregister("semantic_search");
|
||||
return result;
|
||||
};
|
||||
registerRootedTools(rootDir);
|
||||
// `submit_plan` is always in the spec list so the prefix cache stays
|
||||
// stable across plan-mode toggles (Pillar 1). The tool itself is a
|
||||
@@ -125,6 +135,14 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
// menu into a submit_plan body. Keeping it always-registered
|
||||
// preserves the prefix cache across plan-mode toggles.
|
||||
registerChoiceTool(tools);
|
||||
// `todo_write` — lightweight in-session task tracker, no approval gate.
|
||||
// Independent of plan mode (readOnly=true so it stays callable in /plan).
|
||||
registerTodoTool(tools);
|
||||
// `create_skill` / `add_mcp_server` — let the model scaffold from chat.
|
||||
// Both writes go through the same paths the wizard / `/skill new` use,
|
||||
// so the on-disk shape stays one source of truth. New servers take
|
||||
// effect on next launch (no live client churn).
|
||||
registerScaffoldTools(tools, { projectRoot: rootDir });
|
||||
// `run_skill` is intentionally NOT registered here — App.tsx wires it
|
||||
// up with the subagent runner attached, so `runAs: subagent` skills
|
||||
// can spawn isolated child loops. Doing it here would mean the App's
|
||||
@@ -135,7 +153,11 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
// on-disk index already exists, skips entirely otherwise. Setup
|
||||
// happens via the explicit `reasonix index` command — never
|
||||
// by surprise on launch.
|
||||
const semantic = await bootstrapSemanticSearchInCodeMode(tools, rootDir);
|
||||
markPhase("semantic_bootstrap_start");
|
||||
const semantic = await reBootstrapSemantic(rootDir);
|
||||
markPhase(
|
||||
semantic.enabled ? "semantic_bootstrap_done_enabled" : "semantic_bootstrap_done_skipped",
|
||||
);
|
||||
|
||||
process.stderr.write(
|
||||
`▸ reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}" · ${tools.size} native tool(s)${
|
||||
@@ -143,6 +165,13 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
}\n`,
|
||||
);
|
||||
|
||||
const foreign = detectForeignAgentPlatform(rootDir);
|
||||
if (foreign) {
|
||||
process.stderr.write(
|
||||
`⚠ workspace contains another agent platform's files (${foreign.join(", ")}). Reasonix Code may read them as project content; relaunch with --dir <your-project> if that's not what you want.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Belt-and-suspenders cleanup: even though spawn(detached:false)
|
||||
// should tie child processes to the parent's lifetime, Windows cmd.exe
|
||||
// wrappers occasionally leak. We DON'T install SIGINT/SIGTERM
|
||||
@@ -175,7 +204,6 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
|
||||
await chatCommand({
|
||||
model: opts.model ?? "deepseek-v4-flash",
|
||||
harvest: opts.harvest ?? false,
|
||||
budgetUsd: opts.budgetUsd,
|
||||
system: codeSystemPrompt(rootDir, {
|
||||
hasSemanticSearch: semantic.enabled,
|
||||
@@ -185,10 +213,17 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
transcript: opts.transcript,
|
||||
session,
|
||||
seedTools: tools,
|
||||
codeMode: { rootDir, jobs, reregisterTools: registerRootedTools },
|
||||
codeMode: {
|
||||
rootDir,
|
||||
jobs,
|
||||
reregisterTools: registerRootedTools,
|
||||
reBootstrapSemantic,
|
||||
},
|
||||
mcp: readConfig().mcp,
|
||||
forceResume: opts.forceResume,
|
||||
forceNew: opts.forceNew,
|
||||
noDashboard: opts.noDashboard,
|
||||
altScreen: opts.altScreen,
|
||||
mouse: opts.mouse,
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { DeepSeekClient } from "../../client.js";
|
||||
import { loadApiKey } from "../../config.js";
|
||||
import { loadApiKey, loadBaseUrl } from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
|
||||
export interface CommitOptions {
|
||||
@@ -267,7 +267,7 @@ export async function commitCommand(opts: CommitOptions = {}): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const client = new DeepSeekClient({ apiKey });
|
||||
const client = new DeepSeekClient({ apiKey, baseUrl: loadBaseUrl() });
|
||||
const model = opts.model ?? DEFAULT_MODEL;
|
||||
const recentCommits = readRecentCommits();
|
||||
|
||||
|
||||
+68
-43
@@ -1,15 +1,21 @@
|
||||
/** Plain-text (not Ink) — must work when everything else is broken. fail → exit 1; warn → exit 0. */
|
||||
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { DeepSeekClient } from "../../client.js";
|
||||
import { defaultConfigPath, readConfig } from "../../config.js";
|
||||
import {
|
||||
defaultConfigPath,
|
||||
loadBaseUrl,
|
||||
readConfig,
|
||||
resolveSemanticEmbeddingConfig,
|
||||
} from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import { loadHooks } from "../../hooks.js";
|
||||
import { indexExists } from "../../index/semantic/builder.js";
|
||||
import { checkOllamaStatus } from "../../index/semantic/ollama-launcher.js";
|
||||
import { listSessions } from "../../memory/session.js";
|
||||
import { resolveDataPath } from "../../tokenizer.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
|
||||
export type DoctorLevel = "ok" | "warn" | "fail";
|
||||
@@ -127,7 +133,7 @@ async function checkApiReach(): Promise<Check> {
|
||||
};
|
||||
}
|
||||
try {
|
||||
const client = new DeepSeekClient({ apiKey: key });
|
||||
const client = new DeepSeekClient({ apiKey: key, baseUrl: loadBaseUrl() });
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), 8_000);
|
||||
let balance: Awaited<ReturnType<DeepSeekClient["getBalance"]>>;
|
||||
@@ -169,32 +175,20 @@ async function checkApiReach(): Promise<Check> {
|
||||
}
|
||||
|
||||
async function checkTokenizer(): Promise<Check> {
|
||||
// The tokenizer file is the gzipped vocab shipped under data/. We
|
||||
// resolve relative to the package's dist/ rather than process.cwd()
|
||||
// — the same logic the runtime uses when loading on first count.
|
||||
const candidates = [
|
||||
join(
|
||||
dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")),
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"data",
|
||||
"deepseek-tokenizer.json.gz",
|
||||
),
|
||||
join(process.cwd(), "data", "deepseek-tokenizer.json.gz"),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) {
|
||||
try {
|
||||
const stat = statSync(p);
|
||||
return {
|
||||
label: "tokenizer ",
|
||||
level: "ok",
|
||||
detail: `${p} (${fmtBytes(stat.size)})`,
|
||||
};
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
// Reuse the runtime's resolver so the doctor never disagrees with what
|
||||
// the tokenizer actually loads — three candidates including a global
|
||||
// npm install probe via createRequire.
|
||||
const path = resolveDataPath();
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const stat = statSync(path);
|
||||
return {
|
||||
label: "tokenizer ",
|
||||
level: "ok",
|
||||
detail: `${path} (${fmtBytes(stat.size)})`,
|
||||
};
|
||||
} catch {
|
||||
/* fall through to warn */
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -259,9 +253,6 @@ async function checkHooks(projectRoot: string): Promise<Check> {
|
||||
}
|
||||
|
||||
async function checkOllama(projectRoot: string): Promise<Check> {
|
||||
// Ollama is only meaningful when an index has been built — otherwise
|
||||
// the daemon being down is just noise. Skip the check entirely on
|
||||
// projects that don't use semantic search.
|
||||
let exists = false;
|
||||
try {
|
||||
exists = await indexExists(projectRoot);
|
||||
@@ -270,48 +261,82 @@ async function checkOllama(projectRoot: string): Promise<Check> {
|
||||
}
|
||||
if (!exists) {
|
||||
return {
|
||||
label: "ollama ",
|
||||
label: "semantic ",
|
||||
level: "ok",
|
||||
detail: "not in use (no semantic index built; `reasonix index` to enable)",
|
||||
};
|
||||
}
|
||||
const meta = readSemanticMeta(projectRoot);
|
||||
if (meta?.provider === "openai-compat") {
|
||||
const resolved = resolveSemanticEmbeddingConfig();
|
||||
if (resolved.provider !== "openai-compat") {
|
||||
return {
|
||||
label: "semantic ",
|
||||
level: "warn",
|
||||
detail: `index uses openai-compat/${meta.model} but current config resolves to ${resolved.provider}/${resolved.model} — rebuild before searching`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "semantic ",
|
||||
level: "ok",
|
||||
detail: `openai-compat · ${resolved.baseUrl} · model ${resolved.model} · api key configured`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const status = await checkOllamaStatus(process.env.REASONIX_EMBED_MODEL ?? "nomic-embed-text");
|
||||
const model = meta?.model || process.env.REASONIX_EMBED_MODEL || "nomic-embed-text";
|
||||
const status = await checkOllamaStatus(model);
|
||||
if (!status.binaryFound) {
|
||||
return {
|
||||
label: "ollama ",
|
||||
label: "semantic ",
|
||||
level: "warn",
|
||||
detail: "binary not on PATH — semantic_search will fail; install from https://ollama.com",
|
||||
detail:
|
||||
"ollama binary not on PATH — semantic_search will fail; install from https://ollama.com",
|
||||
};
|
||||
}
|
||||
if (!status.daemonRunning) {
|
||||
return {
|
||||
label: "ollama ",
|
||||
label: "semantic ",
|
||||
level: "warn",
|
||||
detail: "daemon not running — `ollama serve` (or just call /semantic in TUI to auto-start)",
|
||||
detail:
|
||||
"ollama daemon not running — `ollama serve` (or call /semantic in TUI to auto-start)",
|
||||
};
|
||||
}
|
||||
if (!status.modelPulled) {
|
||||
return {
|
||||
label: "ollama ",
|
||||
label: "semantic ",
|
||||
level: "warn",
|
||||
detail: `model ${status.modelName} not pulled — \`ollama pull ${status.modelName}\``,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "ollama ",
|
||||
label: "semantic ",
|
||||
level: "ok",
|
||||
detail: `daemon up · model ${status.modelName} ready`,
|
||||
detail: `ollama daemon up · model ${status.modelName} ready`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
label: "ollama ",
|
||||
label: "semantic ",
|
||||
level: "warn",
|
||||
detail: `probe failed — ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readSemanticMeta(
|
||||
projectRoot: string,
|
||||
): { provider: "ollama" | "openai-compat"; model: string } | null {
|
||||
try {
|
||||
const raw = readFileSync(join(projectRoot, ".reasonix", "semantic", "index.meta.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as { provider?: string; model?: string };
|
||||
return {
|
||||
provider: parsed.provider === "openai-compat" ? "openai-compat" : "ollama",
|
||||
model: typeof parsed.model === "string" ? parsed.model : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProject(projectRoot: string): Promise<Check> {
|
||||
// Heuristic: a "real" project has either .git, REASONIX.md, or
|
||||
// package.json. Lacking all three, `reasonix code` still works but
|
||||
|
||||
@@ -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":
|
||||
|
||||
+16
-20
@@ -1,34 +1,37 @@
|
||||
/** `reasonix index` — progress writes go to stderr so stdout stays pipeable. */
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import { loadIndexConfig } from "../../config.js";
|
||||
import { loadIndexConfig, resolveSemanticEmbeddingConfig } from "../../config.js";
|
||||
import { buildIndex } from "../../index/semantic/builder.js";
|
||||
import type { BuildProgress, BuildResult, SkipBuckets } from "../../index/semantic/builder.js";
|
||||
import { t } from "../../index/semantic/i18n.js";
|
||||
import { ollamaPreflight } from "../../index/semantic/preflight.js";
|
||||
import { semanticPreflight } from "../../index/semantic/preflight.js";
|
||||
|
||||
export interface IndexCommandOptions {
|
||||
rebuild?: boolean;
|
||||
model?: string;
|
||||
dir?: string;
|
||||
/** Ollama base URL override. */
|
||||
ollamaUrl?: string;
|
||||
/** Skip preflight prompts (yes to all). For scripts that already
|
||||
* know Ollama is set up. Default false. */
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
export async function indexCommand(opts: IndexCommandOptions = {}): Promise<void> {
|
||||
const root = resolve(opts.dir ?? process.cwd());
|
||||
const tty = process.stderr.isTTY === true && process.stdin.isTTY === true;
|
||||
const model = opts.model ?? process.env.REASONIX_EMBED_MODEL ?? "nomic-embed-text";
|
||||
const resolved = resolveSemanticEmbeddingConfig();
|
||||
const embedding =
|
||||
resolved.provider === "ollama"
|
||||
? {
|
||||
...resolved,
|
||||
model: opts.model ?? resolved.model,
|
||||
baseUrl: opts.ollamaUrl ?? resolved.baseUrl,
|
||||
}
|
||||
: {
|
||||
...resolved,
|
||||
model: opts.model ?? resolved.model,
|
||||
};
|
||||
|
||||
// Preflight: detect Ollama state and offer to fix what's missing.
|
||||
// Runs before chunking so the user doesn't watch a scan finish only
|
||||
// to hit "daemon not reachable" on the first embed call.
|
||||
const preflightOk = await ollamaPreflight({
|
||||
model,
|
||||
baseUrl: opts.ollamaUrl,
|
||||
const preflightOk = await semanticPreflight(embedding, {
|
||||
interactive: tty && !opts.yes,
|
||||
yesToAll: opts.yes ?? false,
|
||||
});
|
||||
@@ -40,9 +43,8 @@ export async function indexCommand(opts: IndexCommandOptions = {}): Promise<void
|
||||
let result: BuildResult;
|
||||
try {
|
||||
result = await buildIndex(root, {
|
||||
...embedding,
|
||||
rebuild: opts.rebuild,
|
||||
model,
|
||||
baseUrl: opts.ollamaUrl,
|
||||
indexConfig: loadIndexConfig(),
|
||||
onProgress: (p) => writer.update(p),
|
||||
});
|
||||
@@ -135,7 +137,6 @@ function makeNonTtyWriter(): ProgressWriter {
|
||||
};
|
||||
}
|
||||
|
||||
/** Spinner ticks on its own setInterval so a hung embedder still shows liveness. */
|
||||
function makeTtyWriter(): ProgressWriter {
|
||||
let status = t("progressStarting");
|
||||
let lastLineLen = 0;
|
||||
@@ -152,8 +153,6 @@ function makeTtyWriter(): ProgressWriter {
|
||||
lastLineLen = line.length;
|
||||
};
|
||||
|
||||
// Paint the initial frame immediately so the user sees text on
|
||||
// line one — don't wait for the first interval tick.
|
||||
repaint();
|
||||
const interval = setInterval(repaint, SPINNER_INTERVAL_MS);
|
||||
|
||||
@@ -167,9 +166,6 @@ function makeTtyWriter(): ProgressWriter {
|
||||
const pct = total > 0 ? ((done / total) * 100).toFixed(0) : "0";
|
||||
status = t("progressEmbed", { done, total, pct });
|
||||
}
|
||||
// Repaint immediately on event arrival so the new counter
|
||||
// shows up before the next interval tick (avoids a 120ms lag
|
||||
// on each progress update).
|
||||
repaint();
|
||||
},
|
||||
clear() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** `reasonix mcp browse` — Ink TUI for the MCP marketplace. Lazy-loads pages on scroll. */
|
||||
|
||||
import { Box, Text, render, useApp, useInput } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { inspectMcpServer } from "../../mcp/inspect.js";
|
||||
import type { InspectionReport } from "../../mcp/inspect.js";
|
||||
import { preflightStdioSpec } from "../../mcp/preflight.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { SseTransport } from "../../mcp/sse.js";
|
||||
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
|
||||
@@ -15,6 +16,7 @@ export interface McpInspectOptions {
|
||||
|
||||
export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void> {
|
||||
const spec = parseMcpSpec(opts.spec);
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const transport: McpTransport =
|
||||
spec.transport === "sse"
|
||||
? new SseTransport({ url: spec.url })
|
||||
@@ -35,6 +37,32 @@ export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMcpInspectFailure(err: unknown): string {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const message = error.message;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === "ENOENT") {
|
||||
const command = message.match(/^spawn\s+([^\s]+)\s+ENOENT$/)?.[1] ?? "the command";
|
||||
return `${message} — try: install or verify \`${command}\`, then check the MCP spec's command spelling`;
|
||||
}
|
||||
|
||||
if (code === "ECONNREFUSED") {
|
||||
const target = message.match(/\b(https?:\/\/\S+|\d+\.\d+\.\d+\.\d+:\d+|localhost:\d+)\b/i)?.[1];
|
||||
return `${message} — try: confirm ${target ?? "the MCP server"} is running and the host/port match the spec`;
|
||||
}
|
||||
|
||||
if (/^MCP request initialize \(id=\d+\) timed out after \d+ms$/.test(message)) {
|
||||
return `${message} — try: confirm the target speaks MCP and completes the handshake before the request timeout`;
|
||||
}
|
||||
|
||||
if (/^(empty MCP spec|MCP spec ".*" has name but no command)/.test(message)) {
|
||||
return `${message} — try: pass \`name=command args\` or an http(s):// URL`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function formatReport(nsName: string, r: InspectionReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`MCP server [${nsName}]`);
|
||||
|
||||
@@ -67,11 +67,6 @@ function printReplay(opts: ReplayOptions): void {
|
||||
} else if (stats.prefixHashes.length > 1) {
|
||||
console.log(" (prefix churned — cache-hostile session)");
|
||||
}
|
||||
if (stats.harvestedTurns > 0) {
|
||||
console.log(`harvest (Pillar 2): ${stats.harvestedTurns} turn(s) produced plan state`);
|
||||
console.log(` subgoals: ${stats.totalSubgoals}`);
|
||||
console.log(` uncertainties: ${stats.totalUncertainties}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sliceRecords(records: TranscriptRecord[], opts: ReplayOptions): TranscriptRecord[] {
|
||||
@@ -98,21 +93,6 @@ function renderRecord(rec: TranscriptRecord): void {
|
||||
})()
|
||||
: "";
|
||||
console.log(`${turn} AGENT:${cost}${cache} ${oneLine(rec.content)}`);
|
||||
if (rec.planState) {
|
||||
const ps = rec.planState;
|
||||
if (ps.subgoals.length)
|
||||
console.log(` ‹ subgoals (${ps.subgoals.length}): ${ps.subgoals.join(" · ")}`);
|
||||
if (ps.hypotheses.length)
|
||||
console.log(` ‹ hypotheses (${ps.hypotheses.length}): ${ps.hypotheses.join(" · ")}`);
|
||||
if (ps.uncertainties.length)
|
||||
console.log(
|
||||
` ‹ uncertainties(${ps.uncertainties.length}): ${ps.uncertainties.join(" · ")}`,
|
||||
);
|
||||
if (ps.rejectedPaths.length)
|
||||
console.log(
|
||||
` ‹ rejected (${ps.rejectedPaths.length}): ${ps.rejectedPaths.join(" · ")}`,
|
||||
);
|
||||
}
|
||||
} else if (rec.role === "tool") {
|
||||
const args = rec.args ? ` args=${oneLine(rec.args, 80)}` : "";
|
||||
console.log(`${turn} TOOL ${rec.tool ?? "?"}:${args} → ${oneLine(rec.content, 120)}`);
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
defaultConfigPath,
|
||||
isPlausibleKey,
|
||||
loadApiKey,
|
||||
loadBaseUrl,
|
||||
readConfig,
|
||||
saveApiKey,
|
||||
} from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { preflightStdioSpec } from "../../mcp/preflight.js";
|
||||
import { bridgeMcpTools } from "../../mcp/registry.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { SseTransport } from "../../mcp/sse.js";
|
||||
@@ -26,10 +28,6 @@ export interface RunOptions {
|
||||
task: string;
|
||||
model: string;
|
||||
system: string;
|
||||
/** Opt into Pillar 2 plan-state harvesting. Adds a cheap V3 call per turn. */
|
||||
harvest?: boolean;
|
||||
/** Self-consistency budget. > 1 runs N parallel samples and picks the best. */
|
||||
branch?: number;
|
||||
budgetUsd?: number;
|
||||
/** JSONL transcript path — lets `reasonix replay` / `diff` audit this run. */
|
||||
transcript?: string;
|
||||
@@ -60,7 +58,7 @@ async function ensureApiKey(): Promise<string> {
|
||||
const answer = (await rl.question("API key › ")).trim();
|
||||
if (!answer) continue;
|
||||
if (!isPlausibleKey(answer)) {
|
||||
process.stdout.write("Invalid format. Keys start with 'sk-' and are 30+ chars.\n");
|
||||
process.stdout.write("Key looks too short. Paste the full token (16+ chars, no spaces).\n");
|
||||
continue;
|
||||
}
|
||||
saveApiKey(answer);
|
||||
@@ -103,6 +101,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
: requestedSpecs.length === 1 && opts.mcpPrefix
|
||||
? opts.mcpPrefix
|
||||
: "";
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const transport: McpTransport =
|
||||
spec.transport === "sse"
|
||||
? new SseTransport({ url: spec.url })
|
||||
@@ -144,7 +143,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
if (successCount === 0) tools = undefined;
|
||||
}
|
||||
|
||||
const client = new DeepSeekClient();
|
||||
const client = new DeepSeekClient({ baseUrl: loadBaseUrl() });
|
||||
const prefix = new ImmutablePrefix({
|
||||
system: opts.system,
|
||||
toolSpecs: tools?.specs(),
|
||||
@@ -154,8 +153,6 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
prefix,
|
||||
tools,
|
||||
model: opts.model,
|
||||
harvest: opts.harvest,
|
||||
branch: opts.branch,
|
||||
budgetUsd: opts.budgetUsd,
|
||||
});
|
||||
const prefixHash = prefix.fingerprint;
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function setupCommand(_opts: SetupOptions = {}): Promise<void> {
|
||||
const { waitUntilExit, unmount } = render(
|
||||
<Wizard
|
||||
existingApiKey={existingKey}
|
||||
initial={{ preset: existing.preset, mcp: existing.mcp }}
|
||||
initial={{ preset: existing.preset, mcp: existing.mcp, theme: existing.theme }}
|
||||
onComplete={() => {
|
||||
// Ink handles its own enter-to-exit inside the "saved" step; we
|
||||
// just wait for the app to exit naturally.
|
||||
|
||||
+73
-15
@@ -1,7 +1,19 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { VERSION, compareVersions, getLatestVersion, isNpxInstall } from "../../version.js";
|
||||
import {
|
||||
type InstallSource,
|
||||
VERSION,
|
||||
compareVersions,
|
||||
detectInstallSource,
|
||||
detectNpmInstallPrefix,
|
||||
getLatestVersion,
|
||||
} from "../../version.js";
|
||||
|
||||
export type UpdateAction = "up-to-date" | "newer-local" | "npx-hint" | "run-npm-install";
|
||||
export type UpdateAction =
|
||||
| "up-to-date"
|
||||
| "newer-local"
|
||||
| "npx-hint"
|
||||
| "manual-hint"
|
||||
| "run-install";
|
||||
|
||||
export interface UpdatePlan {
|
||||
action: UpdateAction;
|
||||
@@ -13,10 +25,18 @@ export interface UpdatePlan {
|
||||
export interface PlanUpdateInput {
|
||||
current: string;
|
||||
latest: string;
|
||||
/** Overrides `isNpxInstall()` (tests). */
|
||||
npx?: boolean;
|
||||
installSource: InstallSource;
|
||||
/** Pin npm to this prefix so nvm/fnm can't redirect the install. */
|
||||
npmPrefix?: string | null;
|
||||
}
|
||||
|
||||
export const MANUAL_UPDATE_COMMANDS: readonly string[] = [
|
||||
"npm install -g reasonix@latest",
|
||||
"bun add -g reasonix",
|
||||
"pnpm add -g reasonix@latest",
|
||||
"yarn global add reasonix@latest",
|
||||
];
|
||||
|
||||
/** Pure decision — split out so tests don't need to spawn child processes or hit the network. */
|
||||
export function planUpdate(input: PlanUpdateInput): UpdatePlan {
|
||||
const diff = compareVersions(input.current, input.latest);
|
||||
@@ -29,7 +49,7 @@ export function planUpdate(input: PlanUpdateInput): UpdatePlan {
|
||||
if (diff === 0) {
|
||||
return { action: "up-to-date", message: `reasonix ${input.current} is up to date.` };
|
||||
}
|
||||
if (input.npx) {
|
||||
if (input.installSource === "npx") {
|
||||
return {
|
||||
action: "npx-hint",
|
||||
message: [
|
||||
@@ -40,20 +60,51 @@ export function planUpdate(input: PlanUpdateInput): UpdatePlan {
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
if (input.installSource === "unknown") {
|
||||
return {
|
||||
action: "manual-hint",
|
||||
message: [
|
||||
`reasonix ${input.latest} is available, but the install source could not be determined automatically.`,
|
||||
"run one of these manually based on how you installed reasonix:",
|
||||
...MANUAL_UPDATE_COMMANDS.map((c) => ` ${c}`),
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
const command = buildUpdateCommand(input.installSource, input.npmPrefix ?? null);
|
||||
return {
|
||||
action: "run-npm-install",
|
||||
message: `upgrading reasonix ${input.current} → ${input.latest}`,
|
||||
command: ["npm", "install", "-g", "reasonix@latest"],
|
||||
action: "run-install",
|
||||
message: `upgrading reasonix ${input.current} → ${input.latest} (via ${input.installSource})`,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUpdateCommand(
|
||||
source: Exclude<InstallSource, "npx" | "unknown">,
|
||||
npmPrefix: string | null,
|
||||
): string[] {
|
||||
switch (source) {
|
||||
case "npm":
|
||||
return npmPrefix
|
||||
? ["npm", "--prefix", npmPrefix, "install", "-g", "reasonix@latest"]
|
||||
: ["npm", "install", "-g", "reasonix@latest"];
|
||||
case "bun":
|
||||
return ["bun", "add", "-g", "reasonix"];
|
||||
case "pnpm":
|
||||
return ["pnpm", "add", "-g", "reasonix@latest"];
|
||||
case "yarn":
|
||||
return ["yarn", "global", "add", "reasonix@latest"];
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateCommandOptions {
|
||||
/** Skip spawning npm; print the decision only. */
|
||||
/** Skip spawning the package manager; print the decision only. */
|
||||
dryRun?: boolean;
|
||||
/** Test seam: override the registry lookup. Returns null = offline. */
|
||||
fetchLatest?: () => Promise<string | null>;
|
||||
/** Test seam: override the npx detector. */
|
||||
isNpx?: () => boolean;
|
||||
/** Test seam: override the install-source detector. */
|
||||
detectSource?: () => InstallSource;
|
||||
/** Test seam: override the npm prefix detector. */
|
||||
detectPrefix?: () => string | null;
|
||||
/** Test seam: override the spawner. Must return exit code. */
|
||||
spawnInstall?: (argv: string[]) => Promise<number>;
|
||||
/** Test seam: stdout writer. */
|
||||
@@ -82,7 +133,8 @@ export async function updateCommand(opts: UpdateCommandOptions = {}): Promise<vo
|
||||
const write = opts.write ?? ((m: string) => process.stdout.write(m));
|
||||
const exit = opts.exit ?? ((c: number) => process.exit(c));
|
||||
const fetchLatest = opts.fetchLatest ?? (() => getLatestVersion({ force: true }));
|
||||
const isNpx = opts.isNpx ?? isNpxInstall;
|
||||
const detectSource = opts.detectSource ?? (() => detectInstallSource());
|
||||
const detectPrefix = opts.detectPrefix ?? (() => detectNpmInstallPrefix());
|
||||
const doSpawn = opts.spawnInstall ?? defaultSpawn;
|
||||
|
||||
write(`current: reasonix ${VERSION}\n`);
|
||||
@@ -94,10 +146,16 @@ export async function updateCommand(opts: UpdateCommandOptions = {}): Promise<vo
|
||||
}
|
||||
write(`latest: reasonix ${latest}\n`);
|
||||
|
||||
const plan = planUpdate({ current: VERSION, latest, npx: isNpx() });
|
||||
const installSource = detectSource();
|
||||
const npmPrefix = installSource === "npm" ? detectPrefix() : null;
|
||||
const plan = planUpdate({ current: VERSION, latest, installSource, npmPrefix });
|
||||
write(`\n${plan.message}\n`);
|
||||
|
||||
if (plan.action !== "run-npm-install" || !plan.command) return;
|
||||
if (plan.action === "manual-hint") {
|
||||
exit(1);
|
||||
return;
|
||||
}
|
||||
if (plan.action !== "run-install" || !plan.command) return;
|
||||
if (opts.dryRun) {
|
||||
write(`(dry run) would run: ${plan.command.join(" ")}\n`);
|
||||
return;
|
||||
@@ -105,7 +163,7 @@ export async function updateCommand(opts: UpdateCommandOptions = {}): Promise<vo
|
||||
write(`\nrunning: ${plan.command.join(" ")}\n`);
|
||||
const code = await doSpawn(plan.command);
|
||||
if (code !== 0) {
|
||||
write(`\nnpm exited with code ${code}. upgrade did not complete.\n`);
|
||||
write(`\n${plan.command[0]} exited with code ${code}. upgrade did not complete.\n`);
|
||||
exit(code);
|
||||
}
|
||||
}
|
||||
+47
-41
@@ -5,7 +5,10 @@ import { VERSION } from "../index.js";
|
||||
import { listSessions } from "../memory/session.js";
|
||||
import { applyMemoryStack } from "../memory/user.js";
|
||||
import { ESCALATION_CONTRACT } from "../prompt-fragments.js";
|
||||
import { chatCommand } from "./commands/chat.js";
|
||||
import { resolveContinueFlag, resolveDefaults } from "./resolve.js";
|
||||
import { markPhase } from "./startup-profile.js";
|
||||
|
||||
markPhase("cli_module_loaded");
|
||||
|
||||
const DEFAULT_SYSTEM = `You are Reasonix, a helpful DeepSeek-powered assistant. Be concise and accurate. Use tools when available.
|
||||
|
||||
@@ -28,24 +31,6 @@ Your training data has a cutoff. When an answer's correctness depends on somethi
|
||||
The signal isn't a topic list — it's: "if I'm wrong about this, is it because reality moved on?". If yes, ground the answer in fresh evidence; if no (definitions, mechanisms, well-established APIs), answer from memory.
|
||||
|
||||
${ESCALATION_CONTRACT}`;
|
||||
import { codeCommand } from "./commands/code.js";
|
||||
import { commitCommand } from "./commands/commit.js";
|
||||
import { diffCommand } from "./commands/diff.js";
|
||||
import { doctorCommand } from "./commands/doctor.js";
|
||||
import { eventsCommand } from "./commands/events.js";
|
||||
import { indexCommand } from "./commands/index.js";
|
||||
import { mcpBrowseCommand } from "./commands/mcp-browse.js";
|
||||
import { mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { mcpInstallCommand, mcpListCommand, mcpSearchCommand } from "./commands/mcp.js";
|
||||
import { pruneSessionsCommand } from "./commands/prune-sessions.js";
|
||||
import { replayCommand } from "./commands/replay.js";
|
||||
import { runCommand } from "./commands/run.js";
|
||||
import { sessionsCommand } from "./commands/sessions.js";
|
||||
import { setupCommand } from "./commands/setup.js";
|
||||
import { statsCommand } from "./commands/stats.js";
|
||||
import { updateCommand } from "./commands/update.js";
|
||||
import { versionCommand } from "./commands/version.js";
|
||||
import { resolveContinueFlag, resolveDefaults } from "./resolve.js";
|
||||
|
||||
/** Lenient: malformed → undefined (no cap) so a bad flag doesn't abort launch. */
|
||||
function parseBudgetFlag(raw: number | undefined): number | undefined {
|
||||
@@ -74,6 +59,7 @@ program
|
||||
program.action(async (opts: { continue?: boolean }) => {
|
||||
const cfg = readConfig();
|
||||
if (!cfg.setupCompleted) {
|
||||
const { setupCommand } = await import("./commands/setup.js");
|
||||
await setupCommand({});
|
||||
return;
|
||||
}
|
||||
@@ -84,11 +70,10 @@ program.action(async (opts: { continue?: boolean }) => {
|
||||
() => listSessions()[0],
|
||||
(msg) => process.stderr.write(`${msg}\n`),
|
||||
);
|
||||
const { chatCommand } = await import("./commands/chat.js");
|
||||
await chatCommand({
|
||||
model: defaults.model,
|
||||
system: applyMemoryStack(DEFAULT_SYSTEM, process.cwd()),
|
||||
harvest: defaults.harvest,
|
||||
branch: defaults.branch,
|
||||
session: continueOpts.session,
|
||||
mcp: defaults.mcp,
|
||||
forceResume: continueOpts.forceResume,
|
||||
@@ -99,6 +84,7 @@ program
|
||||
.command("setup")
|
||||
.description(t("cli.setup"))
|
||||
.action(async () => {
|
||||
const { setupCommand } = await import("./commands/setup.js");
|
||||
await setupCommand({});
|
||||
});
|
||||
|
||||
@@ -110,12 +96,14 @@ program
|
||||
.option("-r, --resume", t("ui.resumeHint"))
|
||||
.option("-n, --new", t("ui.newHint"))
|
||||
.option("--transcript <path>", t("ui.transcriptHint"))
|
||||
.option("--harvest", t("ui.harvestHint"))
|
||||
.option("--budget <usd>", t("ui.budgetHint"), (v) => Number.parseFloat(v))
|
||||
.option("--no-dashboard", t("ui.noDashboard"))
|
||||
.option("--no-alt-screen", "keep chat output in shell scrollback (legacy mode, ghost-prone)")
|
||||
.option("--no-mouse", "disable SGR mouse tracking (keeps drag-select 100% native)")
|
||||
.option("--system-append <prompt>", t("ui.systemAppendHint"))
|
||||
.option("--system-append-file <path>", t("ui.systemAppendFileHint"))
|
||||
.action(async (dir: string | undefined, opts) => {
|
||||
const { codeCommand } = await import("./commands/code.js");
|
||||
await codeCommand({
|
||||
dir,
|
||||
model: opts.model,
|
||||
@@ -123,11 +111,12 @@ program
|
||||
transcript: opts.transcript,
|
||||
forceResume: !!opts.resume,
|
||||
forceNew: !!opts.new,
|
||||
harvest: !!opts.harvest,
|
||||
budgetUsd: parseBudgetFlag(opts.budget),
|
||||
noDashboard: opts.dashboard === false,
|
||||
systemAppend: opts.systemAppend,
|
||||
systemAppendFile: opts.systemAppendFile,
|
||||
altScreen: opts.altScreen !== false,
|
||||
mouse: opts.mouse !== false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,8 +127,6 @@ program
|
||||
.option("-s, --system <prompt>", t("ui.systemPromptHint"), DEFAULT_SYSTEM)
|
||||
.option("--transcript <path>", t("ui.transcriptHint"))
|
||||
.option("--preset <name>", t("ui.presetHint"))
|
||||
.option("--harvest", t("ui.harvestOptInHint"))
|
||||
.option("--branch <n>", t("ui.branchHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--budget <usd>", t("ui.budgetHint"), (v) => Number.parseFloat(v))
|
||||
.option("--session <name>", t("ui.sessionNameHint"))
|
||||
.option("--no-session", t("ui.ephemeralHint"))
|
||||
@@ -155,11 +142,11 @@ program
|
||||
.option("--mcp-prefix <str>", t("ui.mcpPrefixHint"))
|
||||
.option("--no-config", t("ui.noConfigHint"))
|
||||
.option("--no-dashboard", t("ui.noDashboard"))
|
||||
.option("--no-alt-screen", "keep chat output in shell scrollback (legacy mode, ghost-prone)")
|
||||
.option("--no-mouse", "disable SGR mouse tracking (keeps drag-select 100% native)")
|
||||
.action(async (opts) => {
|
||||
const defaults = resolveDefaults({
|
||||
model: opts.model,
|
||||
harvest: opts.harvest,
|
||||
branch: opts.branch,
|
||||
mcp: opts.mcp as string[],
|
||||
session: opts.session,
|
||||
preset: opts.preset,
|
||||
@@ -177,12 +164,11 @@ program
|
||||
() => listSessions()[0],
|
||||
(msg) => process.stderr.write(`${msg}\n`),
|
||||
);
|
||||
const { chatCommand } = await import("./commands/chat.js");
|
||||
await chatCommand({
|
||||
model: defaults.model,
|
||||
system: applyMemoryStack(opts.system, process.cwd()),
|
||||
transcript: opts.transcript,
|
||||
harvest: defaults.harvest,
|
||||
branch: defaults.branch,
|
||||
budgetUsd: parseBudgetFlag(opts.budget),
|
||||
session: continueOpts.session,
|
||||
mcp: defaults.mcp,
|
||||
@@ -190,6 +176,8 @@ program
|
||||
forceResume: continueOpts.forceResume,
|
||||
forceNew: !!opts.new,
|
||||
noDashboard: opts.dashboard === false,
|
||||
altScreen: opts.altScreen !== false,
|
||||
mouse: opts.mouse !== false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,8 +187,6 @@ program
|
||||
.option("-m, --model <id>", t("ui.modelIdHint"))
|
||||
.option("-s, --system <prompt>", t("ui.systemPromptHint"), DEFAULT_SYSTEM)
|
||||
.option("--preset <name>", t("ui.presetHintShort"))
|
||||
.option("--harvest", t("ui.harvestHintShort"))
|
||||
.option("--branch <n>", t("ui.branchHintShort"), (v) => Number.parseInt(v, 10))
|
||||
.option("--budget <usd>", t("ui.budgetHintShort"), (v) => Number.parseFloat(v))
|
||||
.option("--transcript <path>", t("ui.transcriptHintShort"))
|
||||
.option(
|
||||
@@ -214,18 +200,15 @@ program
|
||||
.action(async (task: string, opts) => {
|
||||
const defaults = resolveDefaults({
|
||||
model: opts.model,
|
||||
harvest: opts.harvest,
|
||||
branch: opts.branch,
|
||||
mcp: opts.mcp as string[],
|
||||
preset: opts.preset,
|
||||
noConfig: opts.config === false,
|
||||
});
|
||||
const { runCommand } = await import("./commands/run.js");
|
||||
await runCommand({
|
||||
task,
|
||||
model: defaults.model,
|
||||
system: applyMemoryStack(opts.system, process.cwd()),
|
||||
harvest: defaults.harvest,
|
||||
branch: defaults.branch,
|
||||
budgetUsd: parseBudgetFlag(opts.budget),
|
||||
transcript: opts.transcript,
|
||||
mcp: defaults.mcp,
|
||||
@@ -236,7 +219,8 @@ program
|
||||
program
|
||||
.command("stats [transcript]")
|
||||
.description(t("cli.stats"))
|
||||
.action((transcript: string | undefined) => {
|
||||
.action(async (transcript: string | undefined) => {
|
||||
const { statsCommand } = await import("./commands/stats.js");
|
||||
statsCommand({ transcript });
|
||||
});
|
||||
|
||||
@@ -244,6 +228,7 @@ program
|
||||
.command("doctor")
|
||||
.description(t("cli.doctor"))
|
||||
.action(async () => {
|
||||
const { doctorCommand } = await import("./commands/doctor.js");
|
||||
await doctorCommand();
|
||||
});
|
||||
|
||||
@@ -253,6 +238,7 @@ program
|
||||
.option("-m, --model <id>", t("ui.modelOverrideFlash"))
|
||||
.option("-y, --yes", t("ui.skipConfirmHint"))
|
||||
.action(async (opts) => {
|
||||
const { commitCommand } = await import("./commands/commit.js");
|
||||
await commitCommand({ model: opts.model, yes: !!opts.yes });
|
||||
});
|
||||
|
||||
@@ -260,7 +246,8 @@ program
|
||||
.command("sessions [name]")
|
||||
.description(t("cli.sessions"))
|
||||
.option("-v, --verbose", t("ui.verboseHint"))
|
||||
.action((name: string | undefined, opts) => {
|
||||
.action(async (name: string | undefined, opts) => {
|
||||
const { sessionsCommand } = await import("./commands/sessions.js");
|
||||
sessionsCommand({ name, verbose: !!opts.verbose });
|
||||
});
|
||||
|
||||
@@ -269,7 +256,8 @@ program
|
||||
.description(t("cli.pruneSessions"))
|
||||
.option("--days <n>", t("ui.pruneDaysHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--dry-run", t("ui.pruneDryRunHint"))
|
||||
.action((opts) => {
|
||||
.action(async (opts) => {
|
||||
const { pruneSessionsCommand } = await import("./commands/prune-sessions.js");
|
||||
pruneSessionsCommand({ days: opts.days, dryRun: !!opts.dryRun });
|
||||
});
|
||||
|
||||
@@ -281,7 +269,8 @@ program
|
||||
.option("--tail <n>", t("ui.eventTailHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--json", t("ui.jsonHint"))
|
||||
.option("--projection", t("ui.projectionHint"))
|
||||
.action((name: string, opts) => {
|
||||
.action(async (name: string, opts) => {
|
||||
const { eventsCommand } = await import("./commands/events.js");
|
||||
eventsCommand({
|
||||
name,
|
||||
type: opts.type,
|
||||
@@ -299,6 +288,7 @@ program
|
||||
.option("--head <n>", t("ui.headHint"), (v) => Number.parseInt(v, 10))
|
||||
.option("--tail <n>", t("ui.tailHint"), (v) => Number.parseInt(v, 10))
|
||||
.action(async (transcript: string, opts) => {
|
||||
const { replayCommand } = await import("./commands/replay.js");
|
||||
await replayCommand({
|
||||
path: transcript,
|
||||
print: !!opts.print,
|
||||
@@ -316,6 +306,7 @@ program
|
||||
.option("--label-a <label>", t("ui.labelAHint"))
|
||||
.option("--label-b <label>", t("ui.labelBHint"))
|
||||
.action(async (a: string, b: string, opts) => {
|
||||
const { diffCommand } = await import("./commands/diff.js");
|
||||
await diffCommand({
|
||||
a,
|
||||
b,
|
||||
@@ -340,6 +331,7 @@ mcp
|
||||
.option("--all", t("ui.mcpAllHint"))
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const { mcpListCommand } = await import("./commands/mcp.js");
|
||||
await mcpListCommand({
|
||||
json: !!opts.json,
|
||||
local: !!opts.local,
|
||||
@@ -363,6 +355,7 @@ mcp
|
||||
.option("--max-pages <n>", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10))
|
||||
.action(async (query: string, opts) => {
|
||||
try {
|
||||
const { mcpSearchCommand } = await import("./commands/mcp.js");
|
||||
await mcpSearchCommand(query, {
|
||||
json: !!opts.json,
|
||||
refresh: !!opts.refresh,
|
||||
@@ -383,6 +376,7 @@ mcp
|
||||
.option("--max-pages <n>", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10))
|
||||
.action(async (name: string, opts) => {
|
||||
try {
|
||||
const { mcpInstallCommand } = await import("./commands/mcp.js");
|
||||
await mcpInstallCommand(name, {
|
||||
refresh: !!opts.refresh,
|
||||
maxPages:
|
||||
@@ -399,6 +393,7 @@ mcp
|
||||
.description(t("ui.mcpBrowseDescription"))
|
||||
.action(async () => {
|
||||
try {
|
||||
const { mcpBrowseCommand } = await import("./commands/mcp-browse.js");
|
||||
await mcpBrowseCommand();
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp browse failed: ${(err as Error).message}\n`);
|
||||
@@ -411,21 +406,31 @@ mcp
|
||||
.description(t("ui.mcpInspectDescription"))
|
||||
.option("--json", t("ui.jsonHintReport"))
|
||||
.action(async (spec: string, opts) => {
|
||||
const { formatMcpInspectFailure, mcpInspectCommand } = await import(
|
||||
"./commands/mcp-inspect.js"
|
||||
);
|
||||
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("version")
|
||||
.description(t("cli.version"))
|
||||
.action(async () => {
|
||||
const { versionCommand } = await import("./commands/version.js");
|
||||
versionCommand();
|
||||
});
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.description(t("cli.update"))
|
||||
.option("--dry-run", t("ui.dryRunHint"))
|
||||
.action(async (opts: { dryRun?: boolean }) => {
|
||||
const { updateCommand } = await import("./commands/update.js");
|
||||
await updateCommand({ dryRun: !!opts.dryRun });
|
||||
});
|
||||
|
||||
@@ -445,6 +450,7 @@ program
|
||||
ollamaUrl?: string;
|
||||
yes?: boolean;
|
||||
}) => {
|
||||
const { indexCommand } = await import("./commands/index.js");
|
||||
await indexCommand(opts);
|
||||
},
|
||||
);
|
||||
|
||||
+1
-15
@@ -6,17 +6,12 @@ import { resolvePreset } from "./ui/presets.js";
|
||||
export interface ResolvedDefaults {
|
||||
model: string;
|
||||
reasoningEffort: "high" | "max";
|
||||
harvest: boolean;
|
||||
branch: number | undefined;
|
||||
mcp: string[];
|
||||
session: string | undefined;
|
||||
}
|
||||
|
||||
export interface RawCliFlags {
|
||||
model?: string;
|
||||
harvest?: boolean;
|
||||
/** From `commander`; already parseInt'd. */
|
||||
branch?: number;
|
||||
mcp?: string[];
|
||||
/** Commander's `--no-session` surfaces as `false`; `--session X` as a string. */
|
||||
session?: string | false;
|
||||
@@ -33,9 +28,6 @@ export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
|
||||
|
||||
const model = flags.model ?? presetSettings.model;
|
||||
const reasoningEffort = presetSettings.reasoningEffort;
|
||||
const harvest = flags.harvest === true ? true : presetSettings.harvest;
|
||||
const branchFromFlag = normalizeBranch(flags.branch);
|
||||
const branch = branchFromFlag ?? (presetSettings.branch > 1 ? presetSettings.branch : undefined);
|
||||
|
||||
// `--mcp` accumulator is [] when absent. Treat empty from flags as
|
||||
// "user didn't pass" → fall through to config. Users who explicitly
|
||||
@@ -44,7 +36,7 @@ export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
|
||||
|
||||
const session = resolveSession(flags.session, cfg.session);
|
||||
|
||||
return { model, reasoningEffort, harvest, branch, mcp, session };
|
||||
return { model, reasoningEffort, mcp, session };
|
||||
}
|
||||
|
||||
function pickPreset(
|
||||
@@ -69,12 +61,6 @@ function isPresetName(s: string): s is PresetName {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBranch(raw: number | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (!Number.isFinite(raw) || raw <= 1) return undefined;
|
||||
return Math.min(raw, 8);
|
||||
}
|
||||
|
||||
function resolveSession(
|
||||
flag: string | false | undefined,
|
||||
configSession: string | null | undefined,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
interface PhaseMark {
|
||||
name: string;
|
||||
t: number;
|
||||
}
|
||||
|
||||
const marks: PhaseMark[] = [];
|
||||
let dumped = false;
|
||||
|
||||
function envFlag(): boolean {
|
||||
const v = process.env.REASONIX_PROFILE_STARTUP;
|
||||
return v === "1" || v === "true" || v === "yes";
|
||||
}
|
||||
|
||||
export function isStartupProfileEnabled(): boolean {
|
||||
return envFlag();
|
||||
}
|
||||
|
||||
export function markPhase(name: string): void {
|
||||
if (!envFlag()) return;
|
||||
marks.push({ name, t: performance.now() });
|
||||
}
|
||||
|
||||
export function dumpStartupProfile(stream: NodeJS.WriteStream = process.stderr): void {
|
||||
if (!envFlag() || dumped || marks.length === 0) return;
|
||||
dumped = true;
|
||||
const totalMs = marks[marks.length - 1]!.t;
|
||||
const widest = String(Math.round(totalMs)).length;
|
||||
const lines: string[] = ["[startup-profile]"];
|
||||
let prev = 0;
|
||||
for (const m of marks) {
|
||||
const cum = Math.round(m.t).toString().padStart(widest);
|
||||
const delta = Math.round(m.t - prev);
|
||||
lines.push(` ${cum}ms ${m.name.padEnd(28)} (+${delta})`);
|
||||
prev = m.t;
|
||||
}
|
||||
lines.push(
|
||||
`─── ${Math.round(totalMs)}ms total · last phase ${marks[marks.length - 1]!.name} · set REASONIX_PROFILE_STARTUP=0 to silence`,
|
||||
);
|
||||
stream.write(`${lines.join("\n")}\n`);
|
||||
}
|
||||
|
||||
export function _resetForTests(): void {
|
||||
marks.length = 0;
|
||||
dumped = false;
|
||||
}
|
||||
+1144
-826
File diff suppressed because it is too large.
Load diff
@@ -1,104 +1,146 @@
|
||||
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 { COLOR, GLYPH } from "./theme.js";
|
||||
import { GLYPH, useColor } from "./theme.js";
|
||||
import type { AtPickerEntry, AtPickerState } from "./useCompletionPickers.js";
|
||||
|
||||
export interface AtMentionSuggestionsProps {
|
||||
/**
|
||||
* Current matching file paths, ranked by the picker. `null` means
|
||||
* "not in @-prefix mode" — render nothing. Empty array means "in @
|
||||
* mode but no files match that partial" — render a hint.
|
||||
*/
|
||||
matches: readonly string[] | null;
|
||||
/** Index (within `matches`) of the currently highlighted row. */
|
||||
state: AtPickerState | null;
|
||||
selectedIndex: number;
|
||||
/** The partial query the user typed after `@`. Shown in the hint row. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `@`-mention picker. Rendered below the input box while the user is
|
||||
* typing an `@…` prefix in code mode. Visual grammar matches the
|
||||
* design's file picker:
|
||||
*
|
||||
* @ files matching "auth" esc
|
||||
* ▸ login.ts src/auth/
|
||||
* refresh.ts src/auth/
|
||||
* validators.ts src/auth/
|
||||
* auth.test.ts tests/
|
||||
* [↑↓] navigate · Tab / ⏎ insert as @path · file content inlined
|
||||
*
|
||||
* Basename in primary cyan (the user's eye lands here first), dir
|
||||
* suffix in dim info color. No solid-bg pill on the selected row —
|
||||
* leading ▸ + bold + brighter color does the same job without the
|
||||
* loud bg block. Mirrors {@link SlashSuggestions}.
|
||||
*/
|
||||
const ROW_WINDOW = 8;
|
||||
|
||||
export function AtMentionSuggestions({
|
||||
matches,
|
||||
state,
|
||||
selectedIndex,
|
||||
query,
|
||||
}: AtMentionSuggestionsProps): React.ReactElement | null {
|
||||
if (matches === null) return null;
|
||||
if (matches.length === 0) {
|
||||
const color = useColor();
|
||||
if (!state) return null;
|
||||
|
||||
const isBrowse = state.kind === "browse";
|
||||
const entries = state.entries;
|
||||
const total = entries.length;
|
||||
const windowStart =
|
||||
total <= ROW_WINDOW
|
||||
? 0
|
||||
: Math.max(0, Math.min(selectedIndex - Math.floor(ROW_WINDOW / 2), total - ROW_WINDOW));
|
||||
const shown = entries.slice(windowStart, windowStart + ROW_WINDOW);
|
||||
const hiddenAbove = windowStart;
|
||||
const hiddenBelow = total - windowStart - shown.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} marginTop={1}>
|
||||
<HeaderRow state={state} hiddenAbove={hiddenAbove} />
|
||||
{total === 0 ? <EmptyRow state={state} color={color} /> : null}
|
||||
{shown.map((entry, i) => (
|
||||
<EntryRow
|
||||
key={`${entry.insertPath}:${entry.isDir ? "d" : "f"}`}
|
||||
entry={entry}
|
||||
isSelected={windowStart + i === selectedIndex}
|
||||
/>
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text dimColor>{` ↓ ${hiddenBelow} below`}</Text> : null}
|
||||
<FooterRow isBrowse={isBrowse} hasFolder={shown.some((e) => e.isDir)} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRow({
|
||||
state,
|
||||
hiddenAbove,
|
||||
}: {
|
||||
state: AtPickerState;
|
||||
hiddenAbove: number;
|
||||
}) {
|
||||
const color = useColor();
|
||||
const total = state.entries.length;
|
||||
const lead = (
|
||||
<Text color={color.primary} bold>
|
||||
{"@ "}
|
||||
</Text>
|
||||
);
|
||||
if (state.kind === "browse") {
|
||||
const where = state.baseDir === "" ? "/" : `${state.baseDir}/`;
|
||||
const counter = state.loading ? "loading…" : `${total} ${total === 1 ? "entry" : "entries"}`;
|
||||
return (
|
||||
<Box paddingX={1} marginTop={1}>
|
||||
<Text color={COLOR.warn} bold>
|
||||
{GLYPH.warn}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color={COLOR.warn}>{`no files match "@${query}"`}</Text>
|
||||
<Text dimColor>{" — keep typing or Backspace; paths resolve from the code root"}</Text>
|
||||
<Box>
|
||||
{lead}
|
||||
<Text dimColor>{`${where} ${counter}`}</Text>
|
||||
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const MAX = 8;
|
||||
const total = matches.length;
|
||||
const windowStart =
|
||||
total <= MAX ? 0 : Math.max(0, Math.min(selectedIndex - Math.floor(MAX / 2), total - MAX));
|
||||
const shown = matches.slice(windowStart, windowStart + MAX);
|
||||
const hiddenAbove = windowStart;
|
||||
const hiddenBelow = total - windowStart - shown.length;
|
||||
const status = state.searching
|
||||
? `searching… ${state.scanned} scanned · ${total} ${total === 1 ? "match" : "matches"}`
|
||||
: `${total} ${total === 1 ? "match" : "matches"} for "${state.filter}"`;
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} marginTop={1}>
|
||||
<Box>
|
||||
<Text color={COLOR.primary} bold>
|
||||
{"@ "}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{query
|
||||
? `${total} match${total === 1 ? "" : "es"} for "${query}"`
|
||||
: `${total} file${total === 1 ? "" : "s"}`}
|
||||
</Text>
|
||||
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
|
||||
</Box>
|
||||
{shown.map((path, i) => (
|
||||
<FileRow key={path} path={path} isSelected={windowStart + i === selectedIndex} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text dimColor>{` ↓ ${hiddenBelow} below`}</Text> : null}
|
||||
<Box marginTop={0}>
|
||||
<Text dimColor>{" ↑↓ navigate · Tab / ⏎ insert as @path · esc cancel"}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
{lead}
|
||||
<Text dimColor>{status}</Text>
|
||||
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ path, isSelected }: { path: string; isSelected: boolean }) {
|
||||
// Split the path so the basename visually pops — same dropdown
|
||||
// affordance as VS Code's Quick Open. Basename in primary cyan
|
||||
// (selected: bold), dir suffix in dim info color so the eye lands
|
||||
// on the filename first and uses the path only as a disambiguator.
|
||||
const slash = path.lastIndexOf("/");
|
||||
const dir = slash >= 0 ? `${path.slice(0, slash)}/` : "";
|
||||
const base = slash >= 0 ? path.slice(slash + 1) : path;
|
||||
function EmptyRow({ state, color }: { state: AtPickerState; color: ReturnType<typeof useColor> }) {
|
||||
if (state.kind === "browse") {
|
||||
if (state.loading) return null;
|
||||
return (
|
||||
<Box>
|
||||
<Text color={color.warn} bold>
|
||||
{GLYPH.warn}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color={color.warn}>empty directory</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (state.searching) {
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>scanning the tree…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
|
||||
{isSelected ? `${GLYPH.cur} ` : " "}
|
||||
<Text color={color.warn} bold>
|
||||
{GLYPH.warn}
|
||||
</Text>
|
||||
<Text color={COLOR.primary} bold={isSelected}>
|
||||
{base.padEnd(20)}
|
||||
<Text> </Text>
|
||||
<Text color={color.warn}>{`no files match "${state.filter}"`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryRow({ entry, isSelected }: { entry: AtPickerEntry; isSelected: boolean }) {
|
||||
const color = useColor();
|
||||
const cursor = isSelected ? `${GLYPH.cur} ` : " ";
|
||||
const labelColor = entry.isDir ? color.accent : color.primary;
|
||||
const labelText = entry.isDir ? `${entry.label}/` : entry.label;
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? color.primary : color.info} bold={isSelected}>
|
||||
{cursor}
|
||||
</Text>
|
||||
{dir ? <Text dimColor>{` ${dir}`}</Text> : null}
|
||||
<Text color={labelColor} bold={isSelected}>
|
||||
{labelText.padEnd(20)}
|
||||
</Text>
|
||||
{entry.dirSuffix ? <Text dimColor>{` ${entry.dirSuffix}`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterRow({ isBrowse, hasFolder }: { isBrowse: boolean; hasFolder: boolean }) {
|
||||
const hint = isBrowse
|
||||
? hasFolder
|
||||
? "↑↓ navigate · Tab drill into folder · ⏎ insert · esc cancel"
|
||||
: "↑↓ navigate · Tab / ⏎ insert as @path · esc cancel"
|
||||
: "↑↓ navigate · Tab / ⏎ insert as @path · esc cancel";
|
||||
return (
|
||||
<Box marginTop={0}>
|
||||
<Text dimColor>{` ${hint}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useMemo, useState } from "react";
|
||||
import type { CheckpointMeta } from "../../code/checkpoints.js";
|
||||
import { fmtAgo } from "../../code/checkpoints.js";
|
||||
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type CheckpointPickerOutcome =
|
||||
| { kind: "restore"; id: string }
|
||||
| { kind: "delete"; id: string }
|
||||
| { kind: "quit" };
|
||||
|
||||
export interface CheckpointPickerProps {
|
||||
checkpoints: ReadonlyArray<CheckpointMeta>;
|
||||
workspace: string;
|
||||
onChoose: (outcome: CheckpointPickerOutcome) => void;
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 6;
|
||||
|
||||
export function CheckpointPicker({
|
||||
checkpoints,
|
||||
workspace,
|
||||
onChoose,
|
||||
pickerPorts,
|
||||
}: CheckpointPickerProps): React.ReactElement {
|
||||
const [focus, setFocus] = useState(0);
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
|
||||
|
||||
const snapshot = useMemo(
|
||||
() => ({
|
||||
pickerKind: "checkpoints",
|
||||
title: `restore a checkpoint — ${workspace}`,
|
||||
items: checkpoints.map((c) => {
|
||||
const sizeKb = (c.bytes / 1024).toFixed(1);
|
||||
const tag = c.source === "manual" ? "" : ` (${c.source})`;
|
||||
return {
|
||||
id: c.id,
|
||||
title: `${c.name}${tag}`,
|
||||
subtitle: `${c.fileCount} file${c.fileCount === 1 ? "" : "s"} · ${sizeKb} KB`,
|
||||
badge: c.id.slice(0, 7),
|
||||
meta: fmtAgo(c.createdAt),
|
||||
};
|
||||
}),
|
||||
actions: ["pick", "delete", "cancel"] as const,
|
||||
hint: "↑↓ pick · ⏎ restore · [d] forget · esc quit",
|
||||
}),
|
||||
[checkpoints, workspace],
|
||||
);
|
||||
|
||||
usePickerBroadcast(
|
||||
!!pickerPorts,
|
||||
{
|
||||
...snapshot,
|
||||
actions: [...snapshot.actions],
|
||||
},
|
||||
(res) => {
|
||||
if (res.action === "pick") return onChoose({ kind: "restore", id: res.id });
|
||||
if (res.action === "delete") return onChoose({ kind: "delete", id: res.id });
|
||||
if (res.action === "cancel") return onChoose({ kind: "quit" });
|
||||
},
|
||||
pickerPorts ?? {
|
||||
broadcast: () => undefined,
|
||||
resolverRef: { current: null },
|
||||
snapshotRef: { current: null },
|
||||
},
|
||||
);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.escape) return onChoose({ kind: "quit" });
|
||||
if (ev.upArrow) return setFocus((f) => Math.max(0, f - 1));
|
||||
if (ev.downArrow) return setFocus((f) => Math.min(checkpoints.length - 1, f + 1));
|
||||
if (checkpoints.length === 0) {
|
||||
if (ev.return) return onChoose({ kind: "quit" });
|
||||
return;
|
||||
}
|
||||
const target = checkpoints[focus];
|
||||
if (!target) return;
|
||||
if (ev.return) return onChoose({ kind: "restore", id: target.id });
|
||||
if (ev.input === "q") return onChoose({ kind: "quit" });
|
||||
if (ev.input === "d") return onChoose({ kind: "delete", id: target.id });
|
||||
});
|
||||
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(focus - Math.floor(visibleCount / 2), checkpoints.length - visibleCount),
|
||||
);
|
||||
const end = Math.min(checkpoints.length, start + visibleCount);
|
||||
const shown = checkpoints.slice(start, end);
|
||||
const hiddenBelow = checkpoints.length - end;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text bold color={TONE.brand}>
|
||||
{" ◈ REASONIX · pick a checkpoint "}
|
||||
</Text>
|
||||
<Text color={FG.meta}>{` · ${workspace}`}</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
{checkpoints.length === 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>
|
||||
{" no checkpoints in this workspace yet — see /checkpoint to make one"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
shown.map((c, i) => <CheckpointRow key={c.id} info={c} focused={start + i === focus} />)
|
||||
)}
|
||||
{hiddenBelow > 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>
|
||||
{checkpoints.length === 0
|
||||
? " esc quit"
|
||||
: " ↑↓ pick · ⏎ restore · [d] forget · esc quit"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckpointRow({
|
||||
info,
|
||||
focused,
|
||||
}: {
|
||||
info: CheckpointMeta;
|
||||
focused: boolean;
|
||||
}): React.ReactElement {
|
||||
const tag = info.source === "manual" ? "" : ` (${info.source})`;
|
||||
const sizeKb = (info.bytes / 1024).toFixed(1);
|
||||
const time = fmtAgo(info.createdAt);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
|
||||
<Text color={FG.meta}>{info.id.slice(0, 7).padEnd(8)}</Text>
|
||||
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
|
||||
{info.name}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{tag}</Text>
|
||||
<Box flexGrow={1} />
|
||||
<Text color={FG.faint}>{`${time.padStart(8)} · `}</Text>
|
||||
<Text
|
||||
color={FG.faint}
|
||||
>{`${info.fileCount} file${info.fileCount === 1 ? "" : "s"}, ${sizeKb} KB`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Modal picker for `ask_choice` — options + optional "type my own" escape hatch. */
|
||||
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import type { ChoiceOption } from "../../tools/choice.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import stringWidth from "string-width";
|
||||
import type { SessionSummary } from "../../telemetry/stats.js";
|
||||
import { ChromeRule } from "./primitives.js";
|
||||
import { COLOR, GRADIENT } from "./theme.js";
|
||||
|
||||
const COLD_START_TURNS = 3;
|
||||
const CACHE_BAR_CELLS = 10;
|
||||
|
||||
export type Preset = "auto" | "flash" | "pro";
|
||||
|
||||
export interface ChromeBarProps {
|
||||
summary: SessionSummary;
|
||||
rootDir?: string;
|
||||
sessionName?: string | null;
|
||||
preset?: Preset;
|
||||
planMode?: boolean;
|
||||
proArmed?: boolean;
|
||||
escalated?: boolean;
|
||||
updateAvailable?: string | null;
|
||||
balance?: { currency: string; total: number } | null;
|
||||
budgetUsd?: number | null;
|
||||
/** Scroll progress: 0 = at latest, 1 = at oldest. Hides the pill at 0. */
|
||||
scrollRatio?: number;
|
||||
}
|
||||
|
||||
export function ChromeBar(props: ChromeBarProps): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = (stdout?.columns ?? 80) - 2; // subtract paddingX={1} on both sides
|
||||
const cold = props.summary.turns <= COLD_START_TURNS;
|
||||
const projectName = props.rootDir ? basename(props.rootDir) : null;
|
||||
const mode = pickModePill(props.planMode, props.preset);
|
||||
const proPill = props.escalated
|
||||
? { label: "⇧ pro", color: COLOR.err }
|
||||
: props.proArmed
|
||||
? { label: "⇧ pro", color: COLOR.warn }
|
||||
: null;
|
||||
|
||||
// Greedy width-aware fit. Layout (each gap is exactly ONE space — single
|
||||
// suffix on update/mode/pro/scroll, single prefix on balance/cache):
|
||||
// [brand][·project][›session]<spacer>[scroll][update][mode][pro]$cost w bal cache █▓▓▓
|
||||
// Always-shown carve out the budget first; optional pieces are dropped
|
||||
// by priority — balance > cache > session > update — until the row fits.
|
||||
// (`flexGrow` spacer can shrink to 0, so no minimum reserve.) Cache is
|
||||
// shown unconditionally now: pre-turn-4 it renders dim with a "—" so
|
||||
// it's "default on" instead of materializing only after a few turns.
|
||||
const SEP = 3; // " · " / " › "
|
||||
const GAP = 1;
|
||||
const scrollLabel =
|
||||
props.scrollRatio !== undefined && props.scrollRatio > 0
|
||||
? `[↑ ${Math.round(props.scrollRatio * 100)}%]`
|
||||
: "";
|
||||
const updateLabel = props.updateAvailable ? `↑ ${props.updateAvailable}` : "";
|
||||
const balanceLabel = props.balance ? formatBalanceLabel(props.balance) : "";
|
||||
const cachePct = Math.round(props.summary.cacheHitRatio * 100);
|
||||
// Use the worst-case rendered cache string (3-digit pct, full bar) so the
|
||||
// shed decision doesn't oscillate as the percentage changes turn-to-turn.
|
||||
const cacheLabel = `cache ${"█".repeat(CACHE_BAR_CELLS)} 100%`;
|
||||
const costLabel = `$${props.summary.totalCostUsd.toFixed(4)}${
|
||||
props.budgetUsd && props.budgetUsd > 0 ? ` / $${props.budgetUsd.toFixed(2)}` : ""
|
||||
}`;
|
||||
|
||||
const brandW = stringWidth("◈ reasonix");
|
||||
const projectW = projectName ? SEP + stringWidth(projectName) : 0;
|
||||
const fixedLeft = brandW + projectW;
|
||||
const scrollW = scrollLabel ? stringWidth(scrollLabel) + GAP : 0;
|
||||
const modeW = mode ? stringWidth(`[${mode.label}]`) + GAP : 0;
|
||||
const proW = proPill ? stringWidth(`[${proPill.label}]`) + GAP : 0;
|
||||
const costW = stringWidth(costLabel);
|
||||
const fixedRight = scrollW + modeW + proW + costW;
|
||||
let budget = cols - fixedLeft - fixedRight;
|
||||
|
||||
const balanceOptW = balanceLabel ? GAP + stringWidth(balanceLabel) : 0;
|
||||
const cacheOptW = GAP + stringWidth(cacheLabel);
|
||||
const sessionOptW = props.sessionName ? SEP + stringWidth(props.sessionName) : 0;
|
||||
const updateOptW = updateLabel ? stringWidth(updateLabel) + GAP : 0;
|
||||
|
||||
const showBalance = balanceOptW > 0 && budget >= balanceOptW;
|
||||
if (showBalance) budget -= balanceOptW;
|
||||
const showCache = budget >= cacheOptW;
|
||||
if (showCache) budget -= cacheOptW;
|
||||
const showSession = sessionOptW > 0 && budget >= sessionOptW;
|
||||
if (showSession) budget -= sessionOptW;
|
||||
const showUpdate = updateOptW > 0 && budget >= updateOptW;
|
||||
if (showUpdate) budget -= updateOptW;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box>
|
||||
<Text bold color={GRADIENT[0]}>
|
||||
{"◈ "}
|
||||
</Text>
|
||||
<Text color={COLOR.brand} bold>
|
||||
reasonix
|
||||
</Text>
|
||||
{projectName ? (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{" · "}
|
||||
</Text>
|
||||
<Text>{projectName}</Text>
|
||||
{showSession && props.sessionName ? (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{" › "}
|
||||
</Text>
|
||||
<Text color={COLOR.info}>{props.sessionName}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{scrollLabel ? (
|
||||
<>
|
||||
<Text color={COLOR.accent} bold>
|
||||
{scrollLabel}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{showUpdate ? (
|
||||
<>
|
||||
<Text color={COLOR.warn} bold>
|
||||
{updateLabel}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{mode ? (
|
||||
<>
|
||||
<Text color={mode.color} bold>
|
||||
{`[${mode.label}]`}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{proPill ? (
|
||||
<>
|
||||
<Text color={proPill.color} bold>
|
||||
{`[${proPill.label}]`}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
<CostPill summary={props.summary} cold={cold} budgetUsd={props.budgetUsd} />
|
||||
{showBalance && props.balance ? (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<BalancePill balance={props.balance} />
|
||||
</>
|
||||
) : null}
|
||||
{showCache ? (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<CachePill ratio={props.summary.cacheHitRatio} cold={cold} pct={cachePct} />
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<ChromeRule />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBalanceLabel(balance: { currency: string; total: number }): string {
|
||||
const sym = balance.currency === "USD" ? "$" : balance.currency === "CNY" ? "¥" : "";
|
||||
const suf = sym ? "" : ` ${balance.currency}`;
|
||||
return `w ${sym}${balance.total.toFixed(2)}${suf}`;
|
||||
}
|
||||
|
||||
function CostPill({
|
||||
summary,
|
||||
cold,
|
||||
budgetUsd,
|
||||
}: {
|
||||
summary: SessionSummary;
|
||||
cold: boolean;
|
||||
budgetUsd?: number | null;
|
||||
}): React.ReactElement {
|
||||
const cost = summary.totalCostUsd;
|
||||
const color = cold ? COLOR.info : sessionCostColor(cost);
|
||||
return (
|
||||
<>
|
||||
<Text color={color} bold={!cold} dimColor={cold}>
|
||||
{`$${cost.toFixed(4)}`}
|
||||
</Text>
|
||||
{budgetUsd && budgetUsd > 0 ? (
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{` / $${budgetUsd.toFixed(2)}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BalancePill({
|
||||
balance,
|
||||
}: {
|
||||
balance: { currency: string; total: number };
|
||||
}): React.ReactElement {
|
||||
const { total } = balance;
|
||||
const color = total < 1 ? COLOR.err : total < 5 ? COLOR.warn : COLOR.ok;
|
||||
return <Text color={color}>{formatBalanceLabel(balance)}</Text>;
|
||||
}
|
||||
|
||||
function CachePill({
|
||||
ratio,
|
||||
cold,
|
||||
pct,
|
||||
}: {
|
||||
ratio: number;
|
||||
cold: boolean;
|
||||
pct: number;
|
||||
}): React.ReactElement {
|
||||
const color = ratio >= 0.7 ? COLOR.ok : ratio >= 0.4 ? COLOR.warn : COLOR.err;
|
||||
const filled = Math.max(0, Math.min(CACHE_BAR_CELLS, Math.round(ratio * CACHE_BAR_CELLS)));
|
||||
const empty = CACHE_BAR_CELLS - filled;
|
||||
return (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{"cache "}
|
||||
</Text>
|
||||
<Text color={cold ? COLOR.info : color} dimColor={cold}>
|
||||
{"█".repeat(filled)}
|
||||
</Text>
|
||||
<Text dimColor>{"░".repeat(empty)}</Text>
|
||||
<Text color={cold ? undefined : color} bold={!cold} dimColor={cold}>
|
||||
{cold && pct === 0 ? " —" : ` ${pct}%`}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function pickModePill(
|
||||
planMode: boolean | undefined,
|
||||
preset: Preset | undefined,
|
||||
): { label: string; color: string } | null {
|
||||
if (planMode) return { label: "PLAN", color: COLOR.err };
|
||||
if (!preset) return null;
|
||||
if (preset === "pro") return { label: "pro", color: COLOR.accent };
|
||||
if (preset === "flash") return { label: "flash", color: COLOR.info };
|
||||
return { label: "auto", color: COLOR.primary };
|
||||
}
|
||||
|
||||
function sessionCostColor(cost: number): string | undefined {
|
||||
if (cost <= 0) return undefined;
|
||||
if (cost >= 5) return COLOR.err;
|
||||
if (cost >= 0.5) return COLOR.warn;
|
||||
return COLOR.ok;
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
const norm = path.replace(/\\/g, "/");
|
||||
const i = norm.lastIndexOf("/");
|
||||
return i === -1 ? norm : norm.slice(i + 1);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
/** `/mcp` browser modal — keyboard-driven server list per design §24. */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { toggleMcpDisabled } from "./mcp-disable.js";
|
||||
|
||||
+10
-2
@@ -1,10 +1,10 @@
|
||||
/** `/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 type { PickerBroadcastPorts } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import type { ApplyAppend } from "./mcp-reconnect-kickoff.js";
|
||||
import type { McpServerSummary } from "./slash/types.js";
|
||||
@@ -24,6 +24,8 @@ export interface McpHubProps {
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
}>;
|
||||
/** Forwarded to the marketplace tab so the web dashboard can drive install / uninstall / refine / load-more. */
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
export function McpHub({
|
||||
@@ -34,6 +36,7 @@ export function McpHub({
|
||||
postInfo,
|
||||
applyAppend,
|
||||
reloadMcp,
|
||||
pickerPorts,
|
||||
}: McpHubProps) {
|
||||
const [tab, setTab] = useState<McpHubTab>(initialTab);
|
||||
|
||||
@@ -65,7 +68,12 @@ export function McpHub({
|
||||
applyAppend={applyAppend}
|
||||
/>
|
||||
) : (
|
||||
<McpMarketplace onClose={onClose} postInfo={postInfo} reloadMcp={reloadMcp} />
|
||||
<McpMarketplace
|
||||
onClose={onClose}
|
||||
postInfo={postInfo}
|
||||
reloadMcp={reloadMcp}
|
||||
pickerPorts={pickerPorts}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
+120
-28
@@ -1,7 +1,6 @@
|
||||
/** `/mcp browse` modal — registry marketplace inside the chat session. */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
specStringFor,
|
||||
} from "../../mcp/registry-fetch.js";
|
||||
import type { RegistryEntry } from "../../mcp/registry-types.js";
|
||||
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { COLOR } from "./theme.js";
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface McpMarketplaceProps {
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
}>;
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -39,6 +40,39 @@ interface State {
|
||||
installedSpecs: string[];
|
||||
}
|
||||
|
||||
export function buildMarketplacePickerSnapshot(args: {
|
||||
filtered: RegistryEntry[];
|
||||
installedSpecs: string[];
|
||||
query: string;
|
||||
status: string;
|
||||
hasMore: boolean;
|
||||
}) {
|
||||
return {
|
||||
pickerKind: "mcp-marketplace" as const,
|
||||
title: `MCP marketplace · ${args.status}`,
|
||||
query: args.query,
|
||||
items: args.filtered.map((e) => {
|
||||
const installedSpec = isInstalled(args.installedSpecs, e);
|
||||
return {
|
||||
id: e.name,
|
||||
title: e.title || e.name,
|
||||
subtitle: e.description?.slice(0, 200) ?? undefined,
|
||||
badge: installedSpec
|
||||
? "installed"
|
||||
: e.source === "official"
|
||||
? "official"
|
||||
: e.source === "smithery"
|
||||
? "smithery"
|
||||
: "local",
|
||||
meta: e.popularity !== undefined ? `★ ${e.popularity.toLocaleString()}` : undefined,
|
||||
};
|
||||
}),
|
||||
actions: ["install", "uninstall", "refine", "load-more", "cancel"] as const,
|
||||
hasMore: args.hasMore,
|
||||
hint: "type filter · ↑↓ pick · ⏎ install/toggle · PgDn load more · esc close",
|
||||
};
|
||||
}
|
||||
|
||||
function rankAndFilter(entries: RegistryEntry[], query: string): RegistryEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q
|
||||
@@ -66,7 +100,7 @@ function isInstalled(installedSpecs: string[], entry: RegistryEntry): string | n
|
||||
}
|
||||
}
|
||||
|
||||
export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceProps) {
|
||||
export function McpMarketplace({ onClose, postInfo, reloadMcp, pickerPorts }: McpMarketplaceProps) {
|
||||
const [state, setState] = useState<State>({
|
||||
handle: null,
|
||||
loading: true,
|
||||
@@ -128,33 +162,30 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
|
||||
}
|
||||
}, [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`);
|
||||
const doUninstall = useCallback(
|
||||
async (entry: RegistryEntry, installed: string) => {
|
||||
const cfg = readConfig();
|
||||
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
|
||||
writeConfig({ ...cfg, mcp: next });
|
||||
setState((s) => ({ ...s, installedSpecs: next, status: `uninstalled ${entry.name}` }));
|
||||
if (reloadMcp) {
|
||||
try {
|
||||
await reloadMcp();
|
||||
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
|
||||
} catch (err) {
|
||||
postInfo(
|
||||
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
|
||||
}
|
||||
},
|
||||
[postInfo, reloadMcp],
|
||||
);
|
||||
|
||||
const doInstall = useCallback(
|
||||
async (entry: RegistryEntry) => {
|
||||
let install = entry.install;
|
||||
if (!install && entry.source === "smithery") {
|
||||
setState((s) => ({ ...s, loading: true, status: "fetching smithery detail…" }));
|
||||
@@ -176,7 +207,6 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = specStringFor(entry.name, install);
|
||||
const cfg = readConfig();
|
||||
@@ -216,7 +246,69 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
|
||||
setState((s) => ({ ...s, status: `install failed: ${(err as Error).message}` }));
|
||||
}
|
||||
},
|
||||
[state.installedSpecs, postInfo, reloadMcp],
|
||||
[postInfo, reloadMcp],
|
||||
);
|
||||
|
||||
const installOrToggle = useCallback(
|
||||
async (entry: RegistryEntry) => {
|
||||
const installed = isInstalled(state.installedSpecs, entry);
|
||||
if (installed) await doUninstall(entry, installed);
|
||||
else await doInstall(entry);
|
||||
},
|
||||
[state.installedSpecs, doInstall, doUninstall],
|
||||
);
|
||||
|
||||
const pickerSnapshot = useMemo(
|
||||
() =>
|
||||
buildMarketplacePickerSnapshot({
|
||||
filtered,
|
||||
installedSpecs: state.installedSpecs,
|
||||
query: state.query,
|
||||
status: state.status,
|
||||
hasMore: state.handle?.cache.pagination.nextCursor != null,
|
||||
}),
|
||||
[filtered, state.installedSpecs, state.handle, state.query, state.status],
|
||||
);
|
||||
|
||||
usePickerBroadcast(
|
||||
!!pickerPorts,
|
||||
{ ...pickerSnapshot, actions: [...pickerSnapshot.actions] },
|
||||
(res) => {
|
||||
if (res.action === "cancel") return onClose();
|
||||
if (res.action === "refine") {
|
||||
setState((s) => ({ ...s, query: res.query, selected: 0 }));
|
||||
return;
|
||||
}
|
||||
if (res.action === "load-more") {
|
||||
void fetchMore();
|
||||
return;
|
||||
}
|
||||
if (res.action === "install") {
|
||||
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
|
||||
if (!entry) return;
|
||||
if (isInstalled(state.installedSpecs, entry)) {
|
||||
setState((s) => ({ ...s, status: `already installed: ${entry.name}` }));
|
||||
return;
|
||||
}
|
||||
void doInstall(entry);
|
||||
return;
|
||||
}
|
||||
if (res.action === "uninstall") {
|
||||
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
|
||||
if (!entry) return;
|
||||
const installed = isInstalled(state.installedSpecs, entry);
|
||||
if (!installed) {
|
||||
setState((s) => ({ ...s, status: `not installed: ${entry.name}` }));
|
||||
return;
|
||||
}
|
||||
void doUninstall(entry, installed);
|
||||
}
|
||||
},
|
||||
pickerPorts ?? {
|
||||
broadcast: () => undefined,
|
||||
resolverRef: { current: null },
|
||||
snapshotRef: { current: null },
|
||||
},
|
||||
);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { PRESETS, PRESET_DESCRIPTIONS } from "./presets.js";
|
||||
import { PILL_MODEL, Pill, modelBadgeFor } from "./primitives/Pill.js";
|
||||
import { FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type ModelPickerOutcome =
|
||||
| { kind: "select"; id: string }
|
||||
| { kind: "preset"; name: "auto" | "flash" | "pro" }
|
||||
| { kind: "quit" };
|
||||
|
||||
export interface ModelPickerProps {
|
||||
/** API-fetched ids; null means "still loading / offline". */
|
||||
models: ReadonlyArray<string> | null;
|
||||
/** Model id currently active in the loop — marked with the cursor on open. */
|
||||
current: string;
|
||||
/** Used to detect which preset (if any) the loop currently matches. */
|
||||
currentEffort: "high" | "max";
|
||||
currentAutoEscalate: boolean;
|
||||
onChoose: (outcome: ModelPickerOutcome) => void;
|
||||
/** Triggers a refetch when the catalog is null/empty and the user presses [r]. */
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 8;
|
||||
const PRESET_NAMES = ["auto", "flash", "pro"] as const;
|
||||
type PresetName = (typeof PRESET_NAMES)[number];
|
||||
|
||||
type Row = { kind: "preset"; name: PresetName } | { kind: "model"; id: string };
|
||||
|
||||
export function ModelPicker({
|
||||
models,
|
||||
current,
|
||||
currentEffort,
|
||||
currentAutoEscalate,
|
||||
onChoose,
|
||||
onRefresh,
|
||||
}: ModelPickerProps): React.ReactElement {
|
||||
const modelList = (models && models.length > 0 ? models : FALLBACK_MODELS).slice();
|
||||
if (!modelList.includes(current)) modelList.unshift(current);
|
||||
const presetRows: Row[] = PRESET_NAMES.map((name) => ({ kind: "preset", name }));
|
||||
const modelRows: Row[] = modelList.map((id) => ({ kind: "model", id }));
|
||||
const rows: Row[] = [...presetRows, ...modelRows];
|
||||
|
||||
const activePreset = detectActivePreset(current, currentEffort, currentAutoEscalate);
|
||||
const initialIndex = activePreset
|
||||
? PRESET_NAMES.indexOf(activePreset)
|
||||
: presetRows.length + Math.max(0, modelList.indexOf(current));
|
||||
const [focus, setFocus] = useState(initialIndex);
|
||||
const { stdout } = useStdout();
|
||||
const termRows = stdout?.rows ?? 40;
|
||||
const visibleCount = Math.max(6, termRows - PAGE_MARGIN);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.escape) return onChoose({ kind: "quit" });
|
||||
if (ev.upArrow) return setFocus((f) => Math.max(0, f - 1));
|
||||
if (ev.downArrow) return setFocus((f) => Math.min(rows.length - 1, f + 1));
|
||||
if (ev.return) {
|
||||
const target = rows[focus];
|
||||
if (!target) return;
|
||||
if (target.kind === "preset") return onChoose({ kind: "preset", name: target.name });
|
||||
return onChoose({ kind: "select", id: target.id });
|
||||
}
|
||||
if (!ev.input) return;
|
||||
if (ev.input === "q") return onChoose({ kind: "quit" });
|
||||
if (ev.input === "r") onRefresh?.();
|
||||
});
|
||||
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(focus - Math.floor(visibleCount / 2), rows.length - visibleCount),
|
||||
);
|
||||
const end = Math.min(rows.length, start + visibleCount);
|
||||
const shown = rows.slice(start, end);
|
||||
const hiddenAbove = start;
|
||||
const hiddenBelow = rows.length - end;
|
||||
const loading = models === null;
|
||||
const empty = models !== null && models.length === 0;
|
||||
|
||||
let lastSection: "preset" | "model" | null = null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text bold color={TONE.brand}>
|
||||
{" ◈ REASONIX · pick a setup "}
|
||||
</Text>
|
||||
<Text color={FG.meta}>
|
||||
{loading
|
||||
? " · loading catalog…"
|
||||
: empty
|
||||
? " · catalog empty — using known fallbacks"
|
||||
: ` · ${modelList.length} models available`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
{hiddenAbove > 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` … ${hiddenAbove} earlier`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{shown.map((row, i) => {
|
||||
const idx = start + i;
|
||||
const focused = idx === focus;
|
||||
const showHeader = row.kind !== lastSection;
|
||||
lastSection = row.kind;
|
||||
const header = showHeader ? (
|
||||
<Box key={`hdr-${row.kind}`} marginTop={idx === 0 ? 0 : 1}>
|
||||
<Text color={FG.meta}>
|
||||
{row.kind === "preset"
|
||||
? " PRESETS · recommended — model + effort + auto-escalate"
|
||||
: " MODELS · raw pick — auto-escalate stays as-is"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
const body =
|
||||
row.kind === "preset" ? (
|
||||
<PresetRow
|
||||
key={`p-${row.name}`}
|
||||
name={row.name}
|
||||
focused={focused}
|
||||
active={activePreset === row.name}
|
||||
/>
|
||||
) : (
|
||||
<ModelRow
|
||||
key={`m-${row.id}`}
|
||||
id={row.id}
|
||||
focused={focused}
|
||||
active={!activePreset && row.id === current}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<React.Fragment key={`row-${idx}`}>
|
||||
{header}
|
||||
{body}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hiddenBelow > 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>{" ↑↓ pick · ⏎ confirm · [r] refresh · esc cancel"}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetRow({
|
||||
name,
|
||||
focused,
|
||||
active,
|
||||
}: {
|
||||
name: PresetName;
|
||||
focused: boolean;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const desc = PRESET_DESCRIPTIONS[name];
|
||||
return (
|
||||
<Box>
|
||||
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
|
||||
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
|
||||
{name.padEnd(8)}
|
||||
</Text>
|
||||
<Text color={focused ? FG.body : FG.meta}>{desc.headline.padEnd(28)}</Text>
|
||||
<Text color={FG.meta}>{` ${desc.cost}`}</Text>
|
||||
{active ? <Text color={TONE.brand}>{" · current"}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
id,
|
||||
focused,
|
||||
active,
|
||||
}: {
|
||||
id: string;
|
||||
focused: boolean;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const badge = modelBadgeFor(id);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
|
||||
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
|
||||
{id.padEnd(24)}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Pill label={badge.label} {...PILL_MODEL[badge.kind]} bold={false} />
|
||||
{active ? <Text color={TONE.brand}>{" · current"}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function detectActivePreset(
|
||||
model: string,
|
||||
effort: "high" | "max",
|
||||
autoEscalate: boolean,
|
||||
): PresetName | null {
|
||||
for (const name of PRESET_NAMES) {
|
||||
const p = PRESETS[name];
|
||||
if (p.model === model && p.reasoningEffort === effort && p.autoEscalate === autoEscalate) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Hard-coded known DeepSeek ids — used when the API catalog hasn't loaded yet so the picker isn't empty on first open. */
|
||||
const FALLBACK_MODELS: ReadonlyArray<string> = [
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
];
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Modal picker for `PlanCheckpointError`: continue / revise / stop. */
|
||||
|
||||
import { Box } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
@@ -41,7 +41,7 @@ function PlanCheckpointConfirmInner({
|
||||
const statuses = buildStatusMap(steps, completedStepIds, stepId, isLast);
|
||||
const subtitle = counter ? `${counter} · ${label}` : label;
|
||||
return (
|
||||
<ApprovalCard tone="ok" glyph="⛁" title="Checkpoint — step done" metaRight={subtitle}>
|
||||
<ApprovalCard tone="ok" glyph="⛁" title={t("planFlow.checkpoint.title")} metaRight={subtitle}>
|
||||
{steps && steps.length > 0 ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<PlanStepList steps={steps} statuses={statuses} focusStepId={stepId} />
|
||||
@@ -52,18 +52,18 @@ function PlanCheckpointConfirmInner({
|
||||
items={[
|
||||
{
|
||||
value: "continue",
|
||||
label: "Continue — run the next step",
|
||||
hint: "Model resumes with the next step.",
|
||||
label: t("planFlow.checkpoint.continue"),
|
||||
hint: t("planFlow.checkpoint.continueHint"),
|
||||
},
|
||||
{
|
||||
value: "revise",
|
||||
label: "Revise — give feedback before the next step",
|
||||
hint: "Stay paused, type guidance; model adjusts the remaining plan.",
|
||||
label: t("planFlow.checkpoint.revise"),
|
||||
hint: t("planFlow.checkpoint.reviseHint"),
|
||||
},
|
||||
{
|
||||
value: "stop",
|
||||
label: "Stop — end the plan here",
|
||||
hint: "Model summarizes what was done and ends.",
|
||||
label: t("planFlow.checkpoint.stop"),
|
||||
hint: t("planFlow.checkpoint.stopHint"),
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as CheckpointChoice)}
|
||||
|
||||
+58
-23
@@ -1,14 +1,16 @@
|
||||
/** Modal-style picker for `submit_plan`: accept / refine / cancel. */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { PlanStepList } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
import { CARD, TONE } from "./theme/tokens.js";
|
||||
import { MarkdownView } from "./markdown-view.js";
|
||||
import { extractOpenQuestionsSection } from "./plan-open-questions.js";
|
||||
import { CARD, FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type PlanConfirmChoice = "approve" | "refine" | "revise" | "cancel";
|
||||
|
||||
@@ -21,57 +23,90 @@ export interface PlanConfirmProps {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
const PLAN_BODY_PREVIEW_LINES = 24;
|
||||
|
||||
function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
|
||||
const stepRows = steps?.length ?? 0;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(16, stepRows + 14) });
|
||||
const hasSteps = stepRows > 0;
|
||||
const openQuestions = extractOpenQuestionsSection(plan);
|
||||
const planLines = plan.split("\n");
|
||||
const truncatedBody = planLines.length > PLAN_BODY_PREVIEW_LINES;
|
||||
const previewBody = truncatedBody ? planLines.slice(0, PLAN_BODY_PREVIEW_LINES).join("\n") : plan;
|
||||
const previewRows = truncatedBody
|
||||
? PLAN_BODY_PREVIEW_LINES
|
||||
: Math.min(planLines.length, PLAN_BODY_PREVIEW_LINES);
|
||||
const reservedFor = hasSteps ? stepRows : previewRows;
|
||||
const oqRows = openQuestions ? openQuestions.split("\n").length : 0;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + oqRows + 14) });
|
||||
|
||||
const hasOpenQuestions =
|
||||
/^#{1,6}\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear)/im.test(plan) ||
|
||||
/^#{1,6}\s*(待确认|开放问题|风险|未知|假设|不确定)/im.test(plan);
|
||||
const refineLabel = t("planFlow.picker.refine");
|
||||
const bannerTemplate = t("planFlow.openQuestionsBanner");
|
||||
const [bannerBefore, bannerAfter] = bannerTemplate.split("{refine}");
|
||||
|
||||
return (
|
||||
<ApprovalCard
|
||||
tone="accent"
|
||||
glyph="⊞"
|
||||
title="Approve plan"
|
||||
metaRight="awaiting"
|
||||
title={t("planFlow.approveCardTitle")}
|
||||
metaRight={t("planFlow.approveCardMetaRight")}
|
||||
metaRightColor={CARD.plan.color}
|
||||
>
|
||||
{hasOpenQuestions ? (
|
||||
<Box marginBottom={1}>
|
||||
{openQuestions ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={TONE.warn}>
|
||||
▲ the plan flags open questions or risks — pick <Text bold>refine</Text> to write
|
||||
concrete answers before the model moves on.
|
||||
{bannerBefore ?? ""}
|
||||
<Text bold>{refineLabel}</Text>
|
||||
{bannerAfter ?? ""}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={TONE.warn} bold>
|
||||
{t("planFlow.openQuestionsHeader")}
|
||||
</Text>
|
||||
<MarkdownView text={openQuestions} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
{steps && steps.length > 0 ? (
|
||||
{hasSteps ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<PlanStepList steps={steps} />
|
||||
<PlanStepList steps={steps!} />
|
||||
</Box>
|
||||
) : plan.trim().length > 0 ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<MarkdownView text={previewBody} />
|
||||
{truncatedBody ? (
|
||||
<Text color={FG.faint}>
|
||||
{t(
|
||||
planLines.length - PLAN_BODY_PREVIEW_LINES === 1
|
||||
? "planFlow.truncatedBodyMore"
|
||||
: "planFlow.truncatedBodyMorePlural",
|
||||
{ n: planLines.length - PLAN_BODY_PREVIEW_LINES },
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<SingleSelect
|
||||
initialValue={hasOpenQuestions ? "refine" : "approve"}
|
||||
initialValue={openQuestions ? "refine" : "approve"}
|
||||
items={[
|
||||
{
|
||||
value: "approve",
|
||||
label: "accept",
|
||||
hint: "run it now, in order",
|
||||
label: t("planFlow.picker.accept"),
|
||||
hint: t("planFlow.picker.acceptHint"),
|
||||
},
|
||||
{
|
||||
value: "refine",
|
||||
label: "refine",
|
||||
hint: "give the agent more guidance, draft a new plan",
|
||||
label: refineLabel,
|
||||
hint: t("planFlow.picker.refineHint"),
|
||||
},
|
||||
{
|
||||
value: "revise",
|
||||
label: "revise",
|
||||
hint: "edit the plan inline before running (skip / reorder steps)",
|
||||
label: t("planFlow.picker.revise"),
|
||||
hint: t("planFlow.picker.reviseHint"),
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "reject",
|
||||
hint: "discard, agent will retry from scratch",
|
||||
label: t("planFlow.picker.reject"),
|
||||
hint: t("planFlow.picker.rejectHint"),
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as PlanConfirmChoice)}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import { ApprovalCard, type ApprovalCardProps } from "./cards/ApprovalCard.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { CARD, FG } from "./theme/tokens.js";
|
||||
import { MarkdownView } from "./markdown-view.js";
|
||||
import { CARD, FG, TONE } from "./theme/tokens.js";
|
||||
import { useTick } from "./ticker.js";
|
||||
|
||||
export type PlanRefineMode =
|
||||
| "approve"
|
||||
| "refine"
|
||||
| "reject"
|
||||
| "checkpoint-revise"
|
||||
| "choice-custom";
|
||||
|
||||
export interface PlanRefineInputProps {
|
||||
mode: "approve" | "refine" | "reject" | "checkpoint-revise" | "choice-custom";
|
||||
mode: PlanRefineMode;
|
||||
/** Open-questions / risks block extracted from the plan, rendered above the input on refine. */
|
||||
questions?: string;
|
||||
onSubmit: (feedback: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -20,51 +31,30 @@ interface ModeMeta {
|
||||
blankHint: string;
|
||||
}
|
||||
|
||||
const MODES: Record<PlanRefineInputProps["mode"], ModeMeta> = {
|
||||
approve: {
|
||||
title: "approving — any last instructions?",
|
||||
glyph: "◇",
|
||||
tone: "user",
|
||||
cursorColor: CARD.user.color,
|
||||
hint: "Answer questions the plan raised, add constraints, or just press Enter to approve as-is.",
|
||||
blankHint: " (Enter with blank = approve without extra instructions.)",
|
||||
},
|
||||
refine: {
|
||||
title: "refining — what should the model change?",
|
||||
glyph: "✎",
|
||||
tone: "warn",
|
||||
cursorColor: CARD.warn.color,
|
||||
hint: "Describe what's wrong or missing, or answer questions the plan raised.",
|
||||
blankHint: " (Enter with blank = ask the model to list concrete questions.)",
|
||||
},
|
||||
reject: {
|
||||
title: "rejecting — tell the model why (optional)",
|
||||
glyph: "✗",
|
||||
tone: "error",
|
||||
cursorColor: CARD.error.color,
|
||||
hint: "Say what the model got wrong about your goal, or what you actually want instead.",
|
||||
blankHint:
|
||||
" (Enter with blank = cancel without explanation; the model will ask what you want.)",
|
||||
},
|
||||
"checkpoint-revise": {
|
||||
title: "revising — what should change before the next step?",
|
||||
glyph: "✎",
|
||||
tone: "warn",
|
||||
cursorColor: CARD.warn.color,
|
||||
hint: "Scope change, skip steps, alternative approach — the model adjusts the remaining plan.",
|
||||
blankHint: " (Enter with blank = continue with the current plan.)",
|
||||
},
|
||||
"choice-custom": {
|
||||
title: "custom answer — type whatever fits",
|
||||
glyph: "⌥",
|
||||
tone: "accent",
|
||||
cursorColor: CARD.plan.color,
|
||||
hint: "Free-form reply. The model reads it verbatim and proceeds — no need to match the listed options.",
|
||||
blankHint: " (Enter with blank = ask the model what you actually want.)",
|
||||
},
|
||||
const MODE_VISUALS: Record<
|
||||
PlanRefineMode,
|
||||
{ glyph: string; tone: ApprovalCardProps["tone"]; cursorColor: string }
|
||||
> = {
|
||||
approve: { glyph: "◇", tone: "user", cursorColor: CARD.user.color },
|
||||
refine: { glyph: "✎", tone: "warn", cursorColor: CARD.warn.color },
|
||||
reject: { glyph: "✗", tone: "error", cursorColor: CARD.error.color },
|
||||
"checkpoint-revise": { glyph: "✎", tone: "warn", cursorColor: CARD.warn.color },
|
||||
"choice-custom": { glyph: "⌥", tone: "accent", cursorColor: CARD.plan.color },
|
||||
};
|
||||
|
||||
export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputProps) {
|
||||
function modeMeta(mode: PlanRefineMode): ModeMeta {
|
||||
const v = MODE_VISUALS[mode];
|
||||
return {
|
||||
title: t(`planFlow.modes.${mode}.title`),
|
||||
hint: t(`planFlow.modes.${mode}.hint`),
|
||||
blankHint: t(`planFlow.modes.${mode}.blankHint`),
|
||||
glyph: v.glyph,
|
||||
tone: v.tone,
|
||||
cursorColor: v.cursorColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanRefineInput({ mode, questions, onSubmit, onCancel }: PlanRefineInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useKeystroke((ev) => {
|
||||
@@ -91,15 +81,24 @@ export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputPro
|
||||
|
||||
const tick = useTick();
|
||||
const cursorOn = Math.floor(tick / 4) % 2 === 0;
|
||||
const meta = MODES[mode];
|
||||
const meta = modeMeta(mode);
|
||||
const showQuestions = mode === "refine" && !!questions && questions.trim().length > 0;
|
||||
|
||||
return (
|
||||
<ApprovalCard
|
||||
tone={meta.tone}
|
||||
glyph={meta.glyph}
|
||||
title={meta.title}
|
||||
footerHint="⏎ send · esc return to picker"
|
||||
footerHint={t("planFlow.refineFooter")}
|
||||
>
|
||||
{showQuestions ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={TONE.warn} bold>
|
||||
{t("planFlow.refineQuestionsHeading")}
|
||||
</Text>
|
||||
<MarkdownView text={questions!} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={FG.sub}>
|
||||
{meta.hint}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Shared Ink block that renders a TypedPlanState. Used by RecordView in
|
||||
* replay/diff TUIs.
|
||||
*
|
||||
* Colors are semantic (not decorative):
|
||||
* - subgoals: cyan — structure / plan
|
||||
* - hypotheses: green — current beliefs (like assistant)
|
||||
* - uncertainties: yellow — attention required (like tool)
|
||||
* - rejected paths: red dim — ruled out (muted, like error-but-resolved)
|
||||
*
|
||||
* Visual: colored bold label + count, then dim items. No solid-bg pill
|
||||
* — every assistant turn surfaces this block, so the bracket-text style
|
||||
* keeps it from shouting four colored bg blocks at the user every turn.
|
||||
* Color alone (the bold label) is enough to identify the field type.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import type { TypedPlanState } from "../../harvest.js";
|
||||
import { COLOR } from "./theme.js";
|
||||
|
||||
type FieldColor = string;
|
||||
|
||||
export function PlanStateBlock({ planState }: { planState: TypedPlanState }) {
|
||||
const fields: Array<[string, string[], FieldColor, boolean]> = [];
|
||||
if (planState.subgoals.length)
|
||||
fields.push(["subgoals", planState.subgoals, COLOR.primary, false]);
|
||||
if (planState.hypotheses.length)
|
||||
fields.push(["hypotheses", planState.hypotheses, COLOR.assistant, false]);
|
||||
if (planState.uncertainties.length)
|
||||
fields.push(["uncertainties", planState.uncertainties, COLOR.warn, false]);
|
||||
if (planState.rejectedPaths.length)
|
||||
fields.push(["rejected", planState.rejectedPaths, COLOR.info, true]);
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{fields.map(([label, items, color, dim]) => (
|
||||
<Box key={label}>
|
||||
<Text color={color} bold dimColor={dim}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text dimColor>{` (${items.length})`}</Text>
|
||||
<Text dimColor>{" · "}</Text>
|
||||
<Text dimColor={dim} color={dim ? undefined : COLOR.info}>
|
||||
{items.join(" · ")}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import type { PlanStep, PlanStepRisk } from "../../tools/plan.js";
|
||||
import { CharBar } from "./char-bar.js";
|
||||
import { COLOR, GLYPH } from "./theme.js";
|
||||
@@ -103,8 +104,15 @@ function PlanStepListInner({ steps, statuses, focusStepId }: PlanStepListProps)
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{showProgress
|
||||
? `${doneCount}/${total} done (${pct}%) · ${total} step${total === 1 ? "" : "s"}`
|
||||
: `${total} step${total === 1 ? "" : "s"}`}
|
||||
? t(
|
||||
total === 1
|
||||
? "planFlow.stepList.counterDoneSingular"
|
||||
: "planFlow.stepList.counterDone",
|
||||
{ done: doneCount, total, pct },
|
||||
)
|
||||
: t(total === 1 ? "planFlow.stepList.counterSingular" : "planFlow.stepList.counter", {
|
||||
total,
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
|
||||
+49
-21
@@ -1,7 +1,6 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform compiles JSX to React.createElement)
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useCursor } from "../../renderer/index.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
import { type MultilineKey, lineAndColumn, processMultilineKey } from "./multiline-keys.js";
|
||||
@@ -20,18 +19,20 @@ import { FG, SURFACE, TONE } from "./theme/tokens.js";
|
||||
|
||||
/** Raw-stdin keystroke bus → multiline reducer; one logical line per Box row, viewport-clipped. */
|
||||
|
||||
/** Pastes shorter than this AND single-line render verbatim; longer ones become a `[paste #N · …]` sentinel chip (#397). */
|
||||
export const INLINE_PASTE_THRESHOLD = 200;
|
||||
|
||||
export function shouldInlinePaste(content: string): boolean {
|
||||
return !content.includes("\n") && content.length <= INLINE_PASTE_THRESHOLD;
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSubmit: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
/**
|
||||
* History recall. Fired when ↑/↓ hits a buffer boundary (empty
|
||||
* buffer, or cursor already at the first/last line of a multi-line
|
||||
* buffer). Parent walks its prompt history and swaps the value via
|
||||
* `onChange`. Absent → keys are consumed silently.
|
||||
*/
|
||||
/** Ctrl+P / Ctrl+N hand off here when no in-buffer cursor move applies — parent walks history and swaps `value` via `onChange`. */
|
||||
onHistoryPrev?: () => void;
|
||||
onHistoryNext?: () => void;
|
||||
}
|
||||
@@ -46,8 +47,12 @@ export function PromptInput({
|
||||
onHistoryNext,
|
||||
}: PromptInputProps) {
|
||||
// Cap at 24 — collapseLinesForDisplay hides content past ~20 logical lines.
|
||||
// Quantize spec.max to 4-row buckets so per-keystroke line-count changes
|
||||
// don't churn viewport-budget; without this every single character that
|
||||
// adds/removes a newline re-dispatches the allocator and reflows layout.
|
||||
const inputLineCount = value.length > 0 ? value.split("\n").length : 1;
|
||||
useReserveRows("input", { min: 1, max: Math.min(inputLineCount + 3, 24) });
|
||||
const reserveMax = Math.min(Math.ceil(inputLineCount / 4) * 4 + 3, 24);
|
||||
useReserveRows("input", { min: 1, max: reserveMax });
|
||||
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
|
||||
@@ -71,15 +76,19 @@ export function PromptInput({
|
||||
const registerPaste = (content: string) => {
|
||||
const v = lastLocalValueRef.current;
|
||||
const c = cursorRef.current;
|
||||
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
|
||||
nextPasteIdRef.current = id + 1;
|
||||
pastesRef.current.set(id, makePasteEntry(id, content));
|
||||
const sentinel = encodePasteSentinel(id);
|
||||
const next = v.slice(0, c) + sentinel + v.slice(c);
|
||||
const insertion = shouldInlinePaste(content)
|
||||
? content
|
||||
: (() => {
|
||||
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
|
||||
nextPasteIdRef.current = id + 1;
|
||||
pastesRef.current.set(id, makePasteEntry(id, content));
|
||||
return encodePasteSentinel(id);
|
||||
})();
|
||||
const next = v.slice(0, c) + insertion + v.slice(c);
|
||||
lastLocalValueRef.current = next;
|
||||
cursorRef.current = c + 1;
|
||||
cursorRef.current = c + insertion.length;
|
||||
onChange(next);
|
||||
setCursor(c + 1);
|
||||
setCursor(c + insertion.length);
|
||||
};
|
||||
|
||||
useKeystroke((ev) => {
|
||||
@@ -147,14 +156,12 @@ export function PromptInput({
|
||||
// a just-cleared buffer and read as residual typed input on dim-poor terminals.
|
||||
const effectivePlaceholder = disabled
|
||||
? (placeholder ?? "…waiting for response…")
|
||||
: (placeholder ?? "type a message · slash for commands · at-sign for files");
|
||||
: (placeholder ?? "ask anything · slash for commands · at-sign for files");
|
||||
|
||||
const lines = value.length > 0 ? value.split("\n") : [""];
|
||||
const accentColor = disabled ? FG.faint : TONE.brand;
|
||||
const cursorVisible = true;
|
||||
const { line: cursorLine, col: cursorCol } = lineAndColumn(value, cursor);
|
||||
// PromptInput paints its own cursor (`▌` / inverse char) — hide the real terminal cursor so it doesn't double up at the bottom of the screen.
|
||||
useCursor({ col: 0, rowFromBottom: 0, visible: false });
|
||||
|
||||
const renderItems = collapseLinesForDisplay(lines, cursorLine);
|
||||
const showHugeBufferHints = lines.length > 20;
|
||||
@@ -281,9 +288,7 @@ export function PromptInput({
|
||||
) : null}
|
||||
{!disabled ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>
|
||||
{" ⏎ send · shift/alt+⏎ newline · ↑↓ history · esc abort · ctrl-c quit"}
|
||||
</Text>
|
||||
<HintRow />
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginTop={1}>
|
||||
@@ -294,6 +299,29 @@ export function PromptInput({
|
||||
);
|
||||
}
|
||||
|
||||
function HintRow(): React.ReactElement {
|
||||
const items: Array<{ key: string; sep: string }> = [
|
||||
{ key: "⏎", sep: "send" },
|
||||
{ key: "⇧⏎", sep: "newline" },
|
||||
{ key: "↑↓", sep: "scroll" },
|
||||
{ key: "^P/^N", sep: "history" },
|
||||
{ key: "esc", sep: "abort" },
|
||||
{ key: "^C", sep: "quit" },
|
||||
];
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text>{" "}</Text>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={item.key}>
|
||||
{i > 0 && <Text color={FG.faint}>{" · "}</Text>}
|
||||
<Text color={FG.meta}>{item.key}</Text>
|
||||
<Text color={FG.faint}>{` ${item.sep}`}</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type LineSegment =
|
||||
| { kind: "text"; text: string; startOffset: number }
|
||||
| { kind: "paste"; id: number; startOffset: number };
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
/**
|
||||
* Shared renderer for a single TranscriptRecord. Used by ReplayApp and
|
||||
* DiffApp — both need the same visual grammar (user cyan, assistant green,
|
||||
* tool yellow, error red, cache badge colored by threshold) so transcripts
|
||||
* look consistent wherever they're displayed.
|
||||
*
|
||||
* Kept small on purpose: no streaming/branch/planState paths (those are
|
||||
* live-chat concerns and never appear in replayed transcripts).
|
||||
*/
|
||||
/** Shared renderer for a single TranscriptRecord — used by ReplayApp and DiffApp. */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import type { TranscriptRecord } from "../../transcript/log.js";
|
||||
import { PlanStateBlock } from "./PlanStateBlock.js";
|
||||
|
||||
export interface RecordViewProps {
|
||||
rec: TranscriptRecord;
|
||||
@@ -58,7 +49,6 @@ export function RecordView({ rec, compact = false }: RecordViewProps) {
|
||||
) : null}
|
||||
{rec.usage ? <CacheBadge usage={rec.usage} /> : null}
|
||||
</Box>
|
||||
{rec.planState ? <PlanStateBlock planState={rec.planState} /> : null}
|
||||
{rec.content ? (
|
||||
<Text>{rec.content}</Text>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { COLOR } from "./theme.js";
|
||||
import { type UiColor, useColor } from "./theme.js";
|
||||
|
||||
export interface SelectItem<V extends string = string> {
|
||||
value: V;
|
||||
@@ -33,6 +33,7 @@ export function SingleSelect<V extends string>({
|
||||
onCancel,
|
||||
footer,
|
||||
}: SingleSelectProps<V>) {
|
||||
const color = useColor();
|
||||
const initialIndex = Math.max(
|
||||
0,
|
||||
items.findIndex((i) => i.value === initialValue && !i.disabled),
|
||||
@@ -64,6 +65,7 @@ export function SingleSelect<V extends string>({
|
||||
item={item}
|
||||
active={i === index}
|
||||
marker={i === index ? "▸" : " "}
|
||||
color={color}
|
||||
/>
|
||||
))}
|
||||
{footer ? (
|
||||
@@ -91,6 +93,7 @@ export function MultiSelect<V extends string>({
|
||||
onCancel,
|
||||
footer,
|
||||
}: MultiSelectProps<V>) {
|
||||
const color = useColor();
|
||||
const [index, setIndex] = useState(() => {
|
||||
const first = items.findIndex((i) => !i.disabled);
|
||||
return first === -1 ? 0 : first;
|
||||
@@ -113,7 +116,6 @@ export function MultiSelect<V extends string>({
|
||||
return next;
|
||||
});
|
||||
} else if (ev.return) {
|
||||
// Catalog order keeps the resulting config.json diff stable across reruns.
|
||||
const ordered = items.filter((i) => selected.has(i.value)).map((i) => i.value);
|
||||
onSubmit(ordered);
|
||||
} else if (ev.escape && onCancel) {
|
||||
@@ -132,6 +134,7 @@ export function MultiSelect<V extends string>({
|
||||
item={item}
|
||||
active={i === index}
|
||||
marker={`${i === index ? "▸" : " "} ${marker}`}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -148,16 +151,18 @@ function SelectRow<V extends string>({
|
||||
item,
|
||||
active,
|
||||
marker,
|
||||
color,
|
||||
}: {
|
||||
item: SelectItem<V>;
|
||||
active: boolean;
|
||||
marker: string;
|
||||
color: UiColor;
|
||||
}) {
|
||||
const color = item.disabled ? COLOR.info : active ? COLOR.primary : undefined;
|
||||
const rowColor = item.disabled ? color.info : active ? color.primary : undefined;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={color} bold={active} dimColor={item.disabled}>
|
||||
<Text color={rowColor} bold={active} dimColor={item.disabled}>
|
||||
{marker} {item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import type { SessionInfo } from "../../memory/session.js";
|
||||
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { FG, TONE, USD_TO_CNY } from "./theme/tokens.js";
|
||||
import { FG, TONE, formatCost } from "./theme/tokens.js";
|
||||
|
||||
export type SessionPickerOutcome =
|
||||
| { kind: "open"; name: string }
|
||||
@@ -16,6 +17,10 @@ export interface SessionPickerProps {
|
||||
sessions: ReadonlyArray<SessionInfo>;
|
||||
workspace: string;
|
||||
onChoose: (outcome: SessionPickerOutcome) => void;
|
||||
/** Live wallet currency from App.tsx; falls back to each session's stored `meta.balanceCurrency` per row. */
|
||||
walletCurrency?: string;
|
||||
/** When provided, broadcasts to the web dashboard so it can resolve via `/api/modal/resolve`. */
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 6;
|
||||
@@ -24,6 +29,8 @@ export function SessionPicker({
|
||||
sessions,
|
||||
workspace,
|
||||
onChoose,
|
||||
walletCurrency,
|
||||
pickerPorts,
|
||||
}: SessionPickerProps): React.ReactElement {
|
||||
const [focus, setFocus] = useState(0);
|
||||
const [renaming, setRenaming] = useState<{ from: string; buf: string } | null>(null);
|
||||
@@ -31,6 +38,53 @@ export function SessionPicker({
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
|
||||
|
||||
const snapshot = useMemo(
|
||||
() => ({
|
||||
pickerKind: "sessions",
|
||||
title: `pick a session — ${workspace}`,
|
||||
items: sessions.map((s) => {
|
||||
const branch = s.meta.branch ?? "main";
|
||||
const summary =
|
||||
s.meta.summary ?? `${s.messageCount} message${s.messageCount === 1 ? "" : "s"}`;
|
||||
const turns = s.meta.turnCount ?? Math.ceil(s.messageCount / 2);
|
||||
const currency = walletCurrency ?? s.meta.balanceCurrency;
|
||||
const costLabel =
|
||||
s.meta.totalCostUsd !== undefined ? formatCost(s.meta.totalCostUsd, currency, 2) : "";
|
||||
return {
|
||||
id: s.name,
|
||||
title: s.name,
|
||||
subtitle: summary,
|
||||
badge: branch,
|
||||
meta: costLabel ? `${turns} turns · ${costLabel}` : `${turns} turns`,
|
||||
};
|
||||
}),
|
||||
actions: ["pick", "delete", "rename", "new", "cancel"] as const,
|
||||
hint: "↑↓ pick · ⏎ open · [n] new · [d] delete · [r] rename · esc quit",
|
||||
}),
|
||||
[sessions, workspace, walletCurrency],
|
||||
);
|
||||
|
||||
usePickerBroadcast(
|
||||
!!pickerPorts,
|
||||
{
|
||||
...snapshot,
|
||||
actions: [...snapshot.actions],
|
||||
},
|
||||
(res) => {
|
||||
if (res.action === "pick") return onChoose({ kind: "open", name: res.id });
|
||||
if (res.action === "delete") return onChoose({ kind: "delete", name: res.id });
|
||||
if (res.action === "rename")
|
||||
return onChoose({ kind: "rename", name: res.id, newName: res.text });
|
||||
if (res.action === "new") return onChoose({ kind: "new" });
|
||||
if (res.action === "cancel") return onChoose({ kind: "quit" });
|
||||
},
|
||||
pickerPorts ?? {
|
||||
broadcast: () => undefined,
|
||||
resolverRef: { current: null },
|
||||
snapshotRef: { current: null },
|
||||
},
|
||||
);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.paste) {
|
||||
if (renaming) setRenaming({ ...renaming, buf: renaming.buf + ev.input });
|
||||
@@ -101,7 +155,14 @@ export function SessionPicker({
|
||||
<Text color={FG.faint}>{" to start a new one"}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
shown.map((s, i) => <SessionRow key={s.name} info={s} focused={start + i === focus} />)
|
||||
shown.map((s, i) => (
|
||||
<SessionRow
|
||||
key={s.name}
|
||||
info={s}
|
||||
focused={start + i === focus}
|
||||
walletCurrency={walletCurrency}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{hiddenBelow > 0 ? (
|
||||
<Box>
|
||||
@@ -135,15 +196,19 @@ export function SessionPicker({
|
||||
function SessionRow({
|
||||
info,
|
||||
focused,
|
||||
}: { info: SessionInfo; focused: boolean }): React.ReactElement {
|
||||
walletCurrency,
|
||||
}: {
|
||||
info: SessionInfo;
|
||||
focused: boolean;
|
||||
walletCurrency?: string;
|
||||
}): React.ReactElement {
|
||||
const branch = info.meta.branch ?? "main";
|
||||
const summary =
|
||||
info.meta.summary ?? `${info.messageCount} message${info.messageCount === 1 ? "" : "s"}`;
|
||||
const turns = info.meta.turnCount ?? Math.ceil(info.messageCount / 2);
|
||||
const costCny =
|
||||
info.meta.totalCostUsd !== undefined
|
||||
? `¥${(info.meta.totalCostUsd * USD_TO_CNY).toFixed(2)}`
|
||||
: "";
|
||||
const currency = walletCurrency ?? info.meta.balanceCurrency;
|
||||
const costLabel =
|
||||
info.meta.totalCostUsd !== undefined ? formatCost(info.meta.totalCostUsd, currency, 2) : "";
|
||||
const time = relativeTime(info.mtime);
|
||||
return (
|
||||
<Box>
|
||||
@@ -156,7 +221,7 @@ function SessionRow({
|
||||
<Box flexGrow={1} />
|
||||
<Text color={FG.faint}>{`${time.padStart(11)} `}</Text>
|
||||
<Text color={FG.faint}>{`${turns} turns`}</Text>
|
||||
{costCny ? <Text color={FG.faint}>{` · ${costCny}`}</Text> : null}
|
||||
{costLabel ? <Text color={FG.faint}>{` · ${costLabel}`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Setup({ onReady }: SetupProps) {
|
||||
return;
|
||||
}
|
||||
if (!isPlausibleKey(trimmed)) {
|
||||
setError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
|
||||
setError("Key looks too short — paste the full token (16+ chars, no spaces).");
|
||||
setValue("");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import { DenyContextInput } from "./DenyContextInput.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig.jsx = "react" needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import type { SlashCommandSpec } from "./slash.js";
|
||||
import { COLOR, GLYPH } from "./theme.js";
|
||||
import { GLYPH, useColor } from "./theme.js";
|
||||
|
||||
export interface SlashArgPickerProps {
|
||||
/**
|
||||
@@ -37,16 +37,13 @@ export function SlashArgPicker({
|
||||
kind,
|
||||
partial,
|
||||
}: SlashArgPickerProps): React.ReactElement | null {
|
||||
// Header row shared by both hint and picker modes — same shape so
|
||||
// the UI looks like the same affordance, just with or without rows
|
||||
// beneath. cmd in accent violet (matches SlashSuggestions), argsHint
|
||||
// in dim, summary in info.
|
||||
const color = useColor();
|
||||
const headerRow = (
|
||||
<Box>
|
||||
<Text color={COLOR.accent} bold>
|
||||
<Text color={color.accent} bold>
|
||||
{"/ "}
|
||||
</Text>
|
||||
<Text color={COLOR.accent} bold>
|
||||
<Text color={color.accent} bold>
|
||||
{`/${spec.cmd}`}
|
||||
</Text>
|
||||
{spec.argsHint ? <Text dimColor>{` ${spec.argsHint}`}</Text> : null}
|
||||
@@ -54,7 +51,6 @@ export function SlashArgPicker({
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Hint mode: header only.
|
||||
if (kind === "hint") {
|
||||
return (
|
||||
<Box paddingX={1} marginTop={1}>
|
||||
@@ -69,10 +65,10 @@ export function SlashArgPicker({
|
||||
<Box flexDirection="column" paddingX={1} marginTop={1}>
|
||||
{headerRow}
|
||||
<Box>
|
||||
<Text color={COLOR.warn} bold>
|
||||
<Text color={color.warn} bold>
|
||||
{GLYPH.warn}
|
||||
</Text>
|
||||
<Text color={COLOR.warn}>{` no match for "${partial}"`}</Text>
|
||||
<Text color={color.warn}>{` no match for "${partial}"`}</Text>
|
||||
<Text dimColor>{" — keep typing, or Backspace to edit"}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -102,12 +98,13 @@ export function SlashArgPicker({
|
||||
}
|
||||
|
||||
function ArgRow({ value, isSelected }: { value: string; isSelected: boolean }) {
|
||||
const color = useColor();
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
|
||||
<Text color={isSelected ? color.primary : color.info} bold={isSelected}>
|
||||
{isSelected ? `${GLYPH.cur} ` : " "}
|
||||
</Text>
|
||||
<Text color={isSelected ? COLOR.user : COLOR.info} bold={isSelected} dimColor={!isSelected}>
|
||||
<Text color={isSelected ? color.user : color.info} bold={isSelected} dimColor={!isSelected}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
+166
-51
@@ -1,78 +1,108 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig.jsx = "react" needs React in value scope for JSX compilation
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import React from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import type { SlashCommandSpec } from "./slash.js";
|
||||
import { COLOR, GLYPH } from "./theme.js";
|
||||
import type { SlashCommandSpec, SlashGroup } from "./slash.js";
|
||||
import { GLYPH, useColor } from "./theme.js";
|
||||
|
||||
const GROUP_MODE_MAX_ROWS = 24;
|
||||
const SEARCH_MODE_MAX_ROWS = 8;
|
||||
const COMMAND_NAME_CELLS = 14;
|
||||
const ARGS_CELLS = 14;
|
||||
|
||||
export interface SlashSuggestionsProps {
|
||||
/**
|
||||
* Current matching suggestions, computed by the parent. `null` means
|
||||
* "not in slash-prefix mode" — render nothing. Empty array means "in
|
||||
* slash mode but no matches" — render the "no matches" hint.
|
||||
*/
|
||||
matches: SlashCommandSpec[] | null;
|
||||
/** Index (within `matches`) of the currently highlighted row. */
|
||||
selectedIndex: number;
|
||||
/** True when input is a bare `/` — render section headers + advanced footer. */
|
||||
groupMode?: boolean;
|
||||
/** Count of hidden `advanced` commands; rendered as a footer hint when groupMode is true. */
|
||||
advancedHidden?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slash-command palette. Rendered below the input box while the user
|
||||
* is typing a `/…` prefix. Visual grammar matches the design doc's
|
||||
* picker style:
|
||||
*
|
||||
* / suggestions esc cancel
|
||||
* ▸ /checkpoint [name] snapshot the workspace
|
||||
* /restore <name> roll back files to a checkpoint
|
||||
* /diff cumulative diff vs session start
|
||||
* [↑↓] navigate · [Tab/⏎] pick
|
||||
*
|
||||
* Cmd tokens render in accent violet (one consistent color for "this
|
||||
* is a command"), descriptions in dim info color. The selected row
|
||||
* gets a leading ▸ + bold cmd; non-selected rows get a 2-space pad in
|
||||
* the same column so the cmd column stays aligned. No solid-bg pill —
|
||||
* the bracket-text pattern from the chrome carries through here.
|
||||
*/
|
||||
const GROUP_LABEL: Record<SlashGroup, string> = {
|
||||
chat: "CHAT",
|
||||
setup: "SETUP",
|
||||
info: "INFO",
|
||||
session: "SESSION",
|
||||
extend: "EXTEND",
|
||||
code: "CODE",
|
||||
jobs: "JOBS",
|
||||
advanced: "ADVANCED",
|
||||
};
|
||||
|
||||
export function SlashSuggestions({
|
||||
matches,
|
||||
selectedIndex,
|
||||
groupMode,
|
||||
advancedHidden,
|
||||
}: SlashSuggestionsProps): React.ReactElement | null {
|
||||
const color = useColor();
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const [rememberedWindowStart, setRememberedWindowStart] = React.useState(0);
|
||||
|
||||
// All hooks must run on every render; the early-return branches below
|
||||
// would otherwise change hook count between renders → "Rendered more
|
||||
// hooks than during the previous render" crash when matches flips
|
||||
// between null/empty and non-empty.
|
||||
const maxRows = groupMode ? GROUP_MODE_MAX_ROWS : SEARCH_MODE_MAX_ROWS;
|
||||
const safeMatches = matches ?? [];
|
||||
const windowStart = computeWindowStart(
|
||||
safeMatches,
|
||||
maxRows,
|
||||
selectedIndex,
|
||||
rememberedWindowStart,
|
||||
groupMode,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
setRememberedWindowStart(windowStart);
|
||||
}, [windowStart]);
|
||||
|
||||
if (matches === null) return null;
|
||||
if (matches.length === 0) {
|
||||
return (
|
||||
<Box paddingX={1} marginTop={1}>
|
||||
<Text color={COLOR.warn} bold>
|
||||
<Text color={color.warn} bold>
|
||||
{GLYPH.warn}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text color={COLOR.warn}>no slash command matches that prefix</Text>
|
||||
<Text color={color.warn}>no slash command matches that prefix</Text>
|
||||
<Text dimColor>{" — Backspace to edit, or /help for the full list"}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Limit rows so the suggestion list never dwarfs the rest of the
|
||||
// UI. Keep the currently-selected row in view by sliding the
|
||||
// window when the selection is near either edge.
|
||||
const MAX = 8;
|
||||
const total = matches.length;
|
||||
const windowStart =
|
||||
total <= MAX ? 0 : Math.max(0, Math.min(selectedIndex - Math.floor(MAX / 2), total - MAX));
|
||||
const shown = matches.slice(windowStart, windowStart + MAX);
|
||||
const items = buildVisibleItems(matches, windowStart, maxRows, groupMode);
|
||||
const shownCommands = items.filter((item) => item.kind === "command");
|
||||
const hiddenAbove = windowStart;
|
||||
const hiddenBelow = total - windowStart - shown.length;
|
||||
const hiddenBelow = total - windowStart - shownCommands.length;
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} marginTop={1}>
|
||||
<Box flexDirection="column" paddingX={1} marginTop={1} flexShrink={0} flexWrap="nowrap">
|
||||
<Box>
|
||||
<Text color={COLOR.accent} bold>
|
||||
<Text color={color.accent} bold>
|
||||
{"/ "}
|
||||
</Text>
|
||||
<Text dimColor>{`${total} command${total === 1 ? "" : "s"}`}</Text>
|
||||
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
|
||||
</Box>
|
||||
{shown.map((spec, i) => (
|
||||
<SuggestionRow key={spec.cmd} spec={spec} isSelected={windowStart + i === selectedIndex} />
|
||||
))}
|
||||
{items.map((item) => {
|
||||
if (item.kind === "group") {
|
||||
return <GroupHeader key={`group:${item.group}:${item.beforeIndex}`} group={item.group} />;
|
||||
}
|
||||
return (
|
||||
<SuggestionRow
|
||||
key={`cmd:${item.spec.group}:${item.spec.cmd}`}
|
||||
spec={item.spec}
|
||||
isSelected={item.index === selectedIndex}
|
||||
columns={cols}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{hiddenBelow > 0 ? <Text dimColor>{` ↓ ${hiddenBelow} below`}</Text> : null}
|
||||
{groupMode && advancedHidden && advancedHidden > 0 ? (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{` + ${advancedHidden} advanced · type a letter to search`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={0}>
|
||||
<Text dimColor>{" ↑↓ navigate · Tab / ⏎ pick · esc cancel"}</Text>
|
||||
</Box>
|
||||
@@ -80,25 +110,110 @@ export function SlashSuggestions({
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelected: boolean }) {
|
||||
export function computeWindowStart(
|
||||
matches: readonly SlashCommandSpec[],
|
||||
maxRows: number,
|
||||
selectedIndex: number,
|
||||
currentWindowStart: number,
|
||||
groupMode = false,
|
||||
): number {
|
||||
if (matches.length <= 0) return 0;
|
||||
const maxWindowStart = Math.max(0, matches.length - 1);
|
||||
let start = Math.max(0, Math.min(currentWindowStart, maxWindowStart));
|
||||
const clampedSelectedIndex = Math.max(0, Math.min(selectedIndex, matches.length - 1));
|
||||
if (clampedSelectedIndex < start) start = clampedSelectedIndex;
|
||||
while (start < clampedSelectedIndex) {
|
||||
const visibleCommandIndexes = buildVisibleItems(matches, start, maxRows, groupMode)
|
||||
.filter((item) => item.kind === "command")
|
||||
.map((item) => item.index);
|
||||
if (visibleCommandIndexes.includes(clampedSelectedIndex)) break;
|
||||
start += 1;
|
||||
}
|
||||
return Math.min(start, maxWindowStart);
|
||||
}
|
||||
|
||||
type VisibleSuggestionItem =
|
||||
| { kind: "group"; group: SlashGroup; beforeIndex: number }
|
||||
| { kind: "command"; spec: SlashCommandSpec; index: number };
|
||||
|
||||
export function buildVisibleItems(
|
||||
matches: readonly SlashCommandSpec[],
|
||||
windowStart: number,
|
||||
maxRows: number,
|
||||
groupMode = false,
|
||||
): VisibleSuggestionItem[] {
|
||||
const out: VisibleSuggestionItem[] = [];
|
||||
for (let idx = windowStart; idx < matches.length && out.length < maxRows; idx += 1) {
|
||||
const spec = matches[idx]!;
|
||||
if (groupMode && shouldShowGroupHeader(matches, idx)) {
|
||||
if (out.length >= maxRows) break;
|
||||
out.push({ kind: "group", group: spec.group, beforeIndex: idx });
|
||||
}
|
||||
if (out.length >= maxRows) break;
|
||||
out.push({ kind: "command", spec, index: idx });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shouldShowGroupHeader(matches: readonly SlashCommandSpec[], idx: number): boolean {
|
||||
return idx === 0 || matches[idx]?.group !== matches[idx - 1]?.group;
|
||||
}
|
||||
|
||||
function GroupHeader({ group }: { group: SlashGroup }): React.ReactElement {
|
||||
return (
|
||||
<Box flexShrink={0} height={1} flexWrap="nowrap">
|
||||
<Text dimColor wrap="truncate">
|
||||
{` ${GROUP_LABEL[group]}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionRow({
|
||||
spec,
|
||||
isSelected,
|
||||
columns,
|
||||
}: {
|
||||
spec: SlashCommandSpec;
|
||||
isSelected: boolean;
|
||||
columns: number;
|
||||
}) {
|
||||
const color = useColor();
|
||||
const name = `/${spec.cmd}`;
|
||||
const argsSuffix = spec.argsHint ? spec.argsHint : "";
|
||||
const key = `slash.${spec.cmd}.description`;
|
||||
const translated = t(key);
|
||||
const summary = translated === key ? spec.summary : translated;
|
||||
const aliasHint = spec.aliases?.length ? ` · /${spec.aliases.join(" /")}` : "";
|
||||
const reservedCells = 2 + COMMAND_NAME_CELLS + ARGS_CELLS + 2 + 2;
|
||||
const summaryBudget = Math.max(8, columns - reservedCells);
|
||||
const summaryText = truncateCells(`${summary}${aliasHint}`, summaryBudget);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
|
||||
<Box flexDirection="row" flexWrap="nowrap" flexShrink={0} height={1} minHeight={1}>
|
||||
<Text color={isSelected ? color.primary : color.info} bold={isSelected} wrap="truncate">
|
||||
{isSelected ? `${GLYPH.cur} ` : " "}
|
||||
</Text>
|
||||
<Text color={COLOR.accent} bold={isSelected}>
|
||||
{name.padEnd(14)}
|
||||
<Text color={color.accent} bold={isSelected} wrap="truncate">
|
||||
{padOrTrim(name, COMMAND_NAME_CELLS)}
|
||||
</Text>
|
||||
<Text dimColor>{argsSuffix.padEnd(14)}</Text>
|
||||
<Text>{" "}</Text>
|
||||
<Text color={isSelected ? COLOR.user : COLOR.info} dimColor={!isSelected}>
|
||||
{summary}
|
||||
<Text dimColor wrap="truncate">
|
||||
{padOrTrim(argsSuffix, ARGS_CELLS)}
|
||||
</Text>
|
||||
<Text wrap="truncate">{" "}</Text>
|
||||
<Text color={isSelected ? color.user : color.info} dimColor={!isSelected} wrap="truncate">
|
||||
{summaryText}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function padOrTrim(value: string, cells: number): string {
|
||||
const trimmed = truncateCells(value, cells);
|
||||
return trimmed.padEnd(cells);
|
||||
}
|
||||
|
||||
function truncateCells(value: string, maxCells: number): string {
|
||||
if (value.length <= maxCells) return value;
|
||||
if (maxCells <= 1) return value.slice(0, Math.max(0, maxCells));
|
||||
return `${value.slice(0, maxCells - 1)}…`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -87,13 +88,13 @@ function ChromeRow({
|
||||
? { label: "⇧ pro", color: COLOR.warn }
|
||||
: null;
|
||||
const projectName = rootDir ? basename(rootDir) : null;
|
||||
const cachePct = Math.round(summary.cacheHitRatio * 100);
|
||||
const cachePct = (summary.cacheHitRatio * 100).toFixed(1);
|
||||
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}` : "";
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import { type SelectItem, SingleSelect } from "./Select.js";
|
||||
import { type ThemeName, listThemeNames } from "./theme/tokens.js";
|
||||
|
||||
export type ThemeChoice = ThemeName | "auto";
|
||||
|
||||
export type ThemePickerOutcome = { kind: "select"; value: ThemeChoice } | { kind: "quit" };
|
||||
|
||||
export function ThemePicker({
|
||||
currentPreference,
|
||||
activeTheme,
|
||||
onChoose,
|
||||
}: {
|
||||
currentPreference: ThemeChoice;
|
||||
activeTheme: ThemeName;
|
||||
onChoose: (outcome: ThemePickerOutcome) => void;
|
||||
}) {
|
||||
const choices: ThemeChoice[] = ["auto", ...listThemeNames()];
|
||||
const items: SelectItem<ThemeChoice>[] = choices.map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
hint: describeTheme(value, currentPreference, activeTheme),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text bold>Theme</Text>
|
||||
<SingleSelect
|
||||
items={items}
|
||||
initialValue={currentPreference}
|
||||
onSubmit={(value) => onChoose({ kind: "select", value })}
|
||||
onCancel={() => onChoose({ kind: "quit" })}
|
||||
footer="↑↓ pick · ⏎ confirm · esc cancel"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function describeTheme(
|
||||
value: ThemeChoice,
|
||||
currentPreference: ThemeChoice,
|
||||
activeTheme: ThemeName,
|
||||
): string {
|
||||
const tags: string[] = [];
|
||||
if (value === currentPreference) tags.push("current preference");
|
||||
if (value === activeTheme) tags.push("active now");
|
||||
if (value === "auto") tags.push("use REASONIX_THEME or default");
|
||||
return tags.join(" · ");
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { FG, TONE } from "./theme/tokens.js";
|
||||
export interface WelcomeBannerProps {
|
||||
/** True when running `reasonix code`. Surfaces code-mode hints. */
|
||||
inCodeMode?: boolean;
|
||||
/** Pinned workspace root — only meaningful in code mode. Surfaced so first-time users see they can pass --dir at next launch. */
|
||||
workspaceRoot?: string;
|
||||
/** Live URL of the embedded dashboard, or null when it isn't running. */
|
||||
dashboardUrl?: string | null;
|
||||
/** Bumped on language change; forces re-render so t() picks up new locale. */
|
||||
@@ -19,6 +21,7 @@ const HINTS = ["/help", "/init", "/memory", "/cost"] as const;
|
||||
|
||||
export function WelcomeBanner({
|
||||
inCodeMode,
|
||||
workspaceRoot,
|
||||
dashboardUrl,
|
||||
}: WelcomeBannerProps): React.ReactElement {
|
||||
const tagline = inCodeMode ? t("ui.taglineCode") : t("ui.taglineChat");
|
||||
@@ -66,6 +69,15 @@ export function WelcomeBanner({
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{inCodeMode && workspaceRoot ? (
|
||||
<Box marginTop={1} flexDirection="row" gap={1}>
|
||||
<Text color={TONE.brand}>{"▸ workspace"}</Text>
|
||||
<Text color={FG.faint}>{"·"}</Text>
|
||||
<Text color={FG.body}>{workspaceRoot}</Text>
|
||||
<Text color={FG.faint}>{" (relaunch with --dir <path> to switch)"}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{dashboardUrl ? (
|
||||
<Box marginTop={1} flexDirection="row" gap={1}>
|
||||
<Text color={TONE.brand} bold>
|
||||
|
||||
+413
-171
@@ -1,32 +1,43 @@
|
||||
/**
|
||||
* First-run / re-configure wizard.
|
||||
*
|
||||
* Walks a new user through: API key → preset pick → MCP server pick →
|
||||
* per-server args → save. Saved output lives in `~/.reasonix/config.json`
|
||||
* so the next `reasonix chat` starts with everything already wired.
|
||||
*
|
||||
* The wizard is the antidote to "too many CLI flags" — a new user should
|
||||
* never have to read `--help` to get MCP + a sensible model combo
|
||||
* working. Everything a user could set via `--mcp`, `--harvest`,
|
||||
* `--branch`, etc. can be picked here in a few arrow-key presses.
|
||||
* Walks a new user through: language → theme → API key → preset pick → MCP
|
||||
* server pick → per-server args → save. Saved output lives in
|
||||
* `~/.reasonix/config.json` so the next `reasonix chat` starts with
|
||||
* everything already wired.
|
||||
*/
|
||||
|
||||
import { mkdirSync, statSync } from "node:fs";
|
||||
import { Box, Text, useApp, useInput } from "ink";
|
||||
import TextInput from "ink-text-input";
|
||||
// biome-ignore lint/style/useImportType: JSX (jsx: "react") needs React as a value at runtime
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type PresetName,
|
||||
type ReasonixConfig,
|
||||
defaultConfigPath,
|
||||
isPlausibleKey,
|
||||
loadTheme,
|
||||
readConfig,
|
||||
redactKey,
|
||||
resolveThemePreference,
|
||||
writeConfig,
|
||||
} from "../../config.js";
|
||||
import {
|
||||
detectSystemLanguage,
|
||||
getLanguage,
|
||||
getSupportedLanguages,
|
||||
notifyLanguageChange,
|
||||
onLanguageChange,
|
||||
setLanguage,
|
||||
t,
|
||||
} from "../../i18n/index.js";
|
||||
import type { LanguageCode } from "../../i18n/types.js";
|
||||
import { type CatalogEntry, MCP_CATALOG } from "../../mcp/catalog.js";
|
||||
import { MultiSelect, type SelectItem, SingleSelect } from "./Select.js";
|
||||
import { PRESET_DESCRIPTIONS } from "./presets.js";
|
||||
import { ThemeProvider, useTheme } from "./theme/context.js";
|
||||
import { type ThemeName, listThemeNames } from "./theme/tokens.js";
|
||||
|
||||
export interface WizardProps {
|
||||
/** Called once the config has been saved. */
|
||||
@@ -39,189 +50,361 @@ export interface WizardProps {
|
||||
initial?: {
|
||||
preset?: PresetName;
|
||||
mcp?: string[];
|
||||
theme?: ThemeName | "auto";
|
||||
};
|
||||
}
|
||||
|
||||
type Step = "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
|
||||
type Step = "language" | "theme" | "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
|
||||
|
||||
interface WizardData {
|
||||
language: LanguageCode;
|
||||
theme: ThemeName;
|
||||
apiKey: string;
|
||||
preset: PresetName;
|
||||
selectedCatalog: string[]; // entries from MCP_CATALOG by `name`
|
||||
/** Captured user inputs per catalog entry that has `userArgs` (e.g. fs dir). */
|
||||
selectedCatalog: string[];
|
||||
catalogArgs: Record<string, string>;
|
||||
}
|
||||
|
||||
const CATALOG_BY_NAME = new Map(MCP_CATALOG.map((e) => [e.name, e]));
|
||||
|
||||
const LANGUAGE_LABELS: Record<LanguageCode, string> = {
|
||||
EN: "English",
|
||||
"zh-CN": "简体中文",
|
||||
};
|
||||
|
||||
export function Wizard({ onComplete, onCancel, existingApiKey, initial }: WizardProps) {
|
||||
const { exit } = useApp();
|
||||
const [step, setStep] = useState<Step>(existingApiKey ? "preset" : "apiKey");
|
||||
const [data, setData] = useState<WizardData>({
|
||||
const [, setLanguageVersion] = useState(0);
|
||||
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
|
||||
|
||||
const [previewTheme, setPreviewTheme] = useState<ThemeName>(() =>
|
||||
resolveThemePreference(initial?.theme ?? loadTheme(), process.env.REASONIX_THEME),
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<Step>("language");
|
||||
const [data, setData] = useState<WizardData>(() => ({
|
||||
language: getLanguage(),
|
||||
theme: resolveThemePreference(initial?.theme ?? loadTheme(), process.env.REASONIX_THEME),
|
||||
apiKey: existingApiKey ?? "",
|
||||
preset: initial?.preset ?? "auto",
|
||||
selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
|
||||
catalogArgs: {},
|
||||
});
|
||||
}));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Global Esc → cancel. Disabled once we've started saving to avoid
|
||||
// ejecting out of a half-written state.
|
||||
useInput((_input, key) => {
|
||||
if (key.escape && step !== "saved" && onCancel) onCancel();
|
||||
});
|
||||
|
||||
if (step === "apiKey") {
|
||||
return (
|
||||
<ApiKeyStep
|
||||
onSubmit={(key) => {
|
||||
setData((d) => ({ ...d, apiKey: key }));
|
||||
setError(null);
|
||||
setStep("preset");
|
||||
}}
|
||||
error={error}
|
||||
onError={setError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "preset") {
|
||||
return (
|
||||
<StepFrame title="Pick a preset" step={1} total={3}>
|
||||
<SingleSelect<PresetName>
|
||||
items={presetItems()}
|
||||
initialValue={data.preset}
|
||||
onSubmit={(preset) => {
|
||||
setData((d) => ({ ...d, preset }));
|
||||
setStep("mcp");
|
||||
const content = (() => {
|
||||
if (step === "language") {
|
||||
return (
|
||||
<LanguageStep
|
||||
initialValue={data.language}
|
||||
onSubmit={(lang) => {
|
||||
setLanguage(lang);
|
||||
notifyLanguageChange();
|
||||
setData((d) => ({ ...d, language: lang }));
|
||||
setStep("theme");
|
||||
}}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] navigate · [Enter] confirm · [Esc] cancel</Text>
|
||||
</Box>
|
||||
</StepFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "mcp") {
|
||||
return (
|
||||
<StepFrame title="Which MCP servers should Reasonix wire up for you?" step={2} total={3}>
|
||||
<MultiSelect
|
||||
items={mcpItems()}
|
||||
initialSelected={data.selectedCatalog}
|
||||
onSubmit={(selected) => {
|
||||
setData((d) => ({ ...d, selectedCatalog: selected }));
|
||||
// Only advance to the args step if any selected entry needs args.
|
||||
const needsArgs = selected.some((name) => CATALOG_BY_NAME.get(name)?.userArgs);
|
||||
setStep(needsArgs ? "mcpArgs" : "review");
|
||||
}}
|
||||
footer="[↑↓] navigate · [Space] toggle · [Enter] confirm · [Esc] cancel · empty = skip"
|
||||
/>
|
||||
</StepFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "mcpArgs") {
|
||||
const pending = data.selectedCatalog.filter((name) => {
|
||||
const entry = CATALOG_BY_NAME.get(name);
|
||||
return entry?.userArgs && !data.catalogArgs[name];
|
||||
});
|
||||
if (pending.length === 0) {
|
||||
setStep("review");
|
||||
return null;
|
||||
);
|
||||
}
|
||||
const currentName = pending[0]!;
|
||||
const entry = CATALOG_BY_NAME.get(currentName)!;
|
||||
return (
|
||||
<McpArgsStep
|
||||
entry={entry}
|
||||
error={error}
|
||||
onSubmit={(value) => {
|
||||
setData((d) => ({
|
||||
...d,
|
||||
catalogArgs: { ...d.catalogArgs, [currentName]: value },
|
||||
}));
|
||||
setError(null);
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "review") {
|
||||
const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
|
||||
return (
|
||||
<StepFrame title="Ready to save" step={3} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<SummaryLine label="API key" value={redactKey(data.apiKey)} />
|
||||
<SummaryLine label="Preset" value={data.preset} />
|
||||
<SummaryLine
|
||||
label="MCP"
|
||||
value={specs.length === 0 ? "(none)" : `${specs.length} server(s)`}
|
||||
/>
|
||||
{specs.map((spec, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: review-only render, order fixed
|
||||
<Box key={i} paddingLeft={14}>
|
||||
<Text dimColor>· {spec}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text>Saves to {defaultConfigPath()}</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[Enter] save · [Esc] cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<ReviewConfirm
|
||||
onConfirm={() => {
|
||||
try {
|
||||
const specsNow = data.selectedCatalog.map((name) =>
|
||||
buildSpec(name, data.catalogArgs),
|
||||
);
|
||||
const prev = readConfig();
|
||||
const next: ReasonixConfig = {
|
||||
...prev,
|
||||
apiKey: data.apiKey,
|
||||
preset: data.preset,
|
||||
mcp: specsNow,
|
||||
setupCompleted: true,
|
||||
};
|
||||
writeConfig(next);
|
||||
setStep("saved");
|
||||
onComplete(next);
|
||||
} catch (e) {
|
||||
setError(`Could not save config: ${(e as Error).message}`);
|
||||
}
|
||||
if (step === "theme") {
|
||||
return (
|
||||
<ThemeStep
|
||||
initialValue={data.theme}
|
||||
onPreview={setPreviewTheme}
|
||||
onSubmit={(theme) => {
|
||||
setData((d) => ({ ...d, theme }));
|
||||
setStep(existingApiKey ? "preset" : "apiKey");
|
||||
}}
|
||||
/>
|
||||
</StepFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "apiKey") {
|
||||
return (
|
||||
<ApiKeyStep
|
||||
onSubmit={(key) => {
|
||||
setData((d) => ({ ...d, apiKey: key }));
|
||||
setError(null);
|
||||
setStep("preset");
|
||||
}}
|
||||
error={error}
|
||||
onError={setError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "preset") {
|
||||
return (
|
||||
<StepFrame title={t("wizard.presetTitle")} step={1} total={3}>
|
||||
<SingleSelect<PresetName>
|
||||
items={presetItems()}
|
||||
initialValue={data.preset}
|
||||
onSubmit={(preset) => {
|
||||
setData((d) => ({ ...d, preset }));
|
||||
setStep("mcp");
|
||||
}}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.selectFooter")}</Text>
|
||||
</Box>
|
||||
</StepFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "mcp") {
|
||||
return (
|
||||
<StepFrame title={t("wizard.mcpTitle")} step={2} total={3}>
|
||||
<MultiSelect
|
||||
items={mcpItems()}
|
||||
initialSelected={data.selectedCatalog}
|
||||
onSubmit={(selected) => {
|
||||
setData((d) => ({ ...d, selectedCatalog: selected }));
|
||||
const needsArgs = selected.some((name) => CATALOG_BY_NAME.get(name)?.userArgs);
|
||||
setStep(needsArgs ? "mcpArgs" : "review");
|
||||
}}
|
||||
footer={t("wizard.mcpFooterMulti")}
|
||||
/>
|
||||
</StepFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "mcpArgs") {
|
||||
const pending = data.selectedCatalog.filter((name) => {
|
||||
const entry = CATALOG_BY_NAME.get(name);
|
||||
return entry?.userArgs && !data.catalogArgs[name];
|
||||
});
|
||||
if (pending.length === 0) {
|
||||
setStep("review");
|
||||
return null;
|
||||
}
|
||||
const currentName = pending[0]!;
|
||||
const entry = CATALOG_BY_NAME.get(currentName)!;
|
||||
return (
|
||||
<McpArgsStep
|
||||
entry={entry}
|
||||
error={error}
|
||||
onSubmit={(value) => {
|
||||
setData((d) => ({
|
||||
...d,
|
||||
catalogArgs: { ...d.catalogArgs, [currentName]: value },
|
||||
}));
|
||||
setError(null);
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "review") {
|
||||
const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
|
||||
return (
|
||||
<StepFrame title={t("wizard.reviewTitle")} step={3} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<SummaryLine
|
||||
label={t("wizard.reviewLabelLanguage")}
|
||||
value={LANGUAGE_LABELS[data.language]}
|
||||
/>
|
||||
<SummaryLine label={t("wizard.reviewLabelApiKey")} value={redactKey(data.apiKey)} />
|
||||
<SummaryLine label={t("wizard.reviewLabelTheme")} value={data.theme} />
|
||||
<SummaryLine label={t("wizard.reviewLabelPreset")} value={data.preset} />
|
||||
<SummaryLine
|
||||
label={t("wizard.reviewLabelMcp")}
|
||||
value={
|
||||
specs.length === 0
|
||||
? t("wizard.reviewMcpNone")
|
||||
: t("wizard.reviewMcpServers", { count: specs.length })
|
||||
}
|
||||
/>
|
||||
{specs.map((spec, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: review-only render, order fixed
|
||||
<Box key={i} paddingLeft={14}>
|
||||
<Text dimColor>· {spec}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text>{t("wizard.reviewSavesTo", { path: defaultConfigPath() })}</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.reviewFooter")}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<ReviewConfirm
|
||||
onConfirm={() => {
|
||||
try {
|
||||
const specsNow = data.selectedCatalog.map((name) =>
|
||||
buildSpec(name, data.catalogArgs),
|
||||
);
|
||||
const prev = readConfig();
|
||||
const next: ReasonixConfig = {
|
||||
...prev,
|
||||
apiKey: data.apiKey,
|
||||
preset: data.preset,
|
||||
theme: data.theme,
|
||||
mcp: specsNow,
|
||||
setupCompleted: true,
|
||||
};
|
||||
writeConfig(next);
|
||||
setStep("saved");
|
||||
onComplete(next);
|
||||
} catch (e) {
|
||||
setError(t("wizard.reviewSaveError", { message: (e as Error).message }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</StepFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
|
||||
<Text bold color="green">
|
||||
{t("wizard.savedTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t("ui.welcome")}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.savedFooter")}</Text>
|
||||
</Box>
|
||||
<ExitOnEnter onExit={exit} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return <ThemeProvider name={previewTheme}>{content}</ThemeProvider>;
|
||||
}
|
||||
|
||||
const THEME_NAMES = listThemeNames();
|
||||
|
||||
function ThemeStep({
|
||||
initialValue,
|
||||
onPreview,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialValue: ThemeName;
|
||||
onPreview: (theme: ThemeName) => void;
|
||||
onSubmit: (theme: ThemeName) => void;
|
||||
}) {
|
||||
const initialIndex = Math.max(0, THEME_NAMES.indexOf(initialValue));
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
const theme = useTheme();
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.upArrow) {
|
||||
const next = (index - 1 + THEME_NAMES.length) % THEME_NAMES.length;
|
||||
setIndex(next);
|
||||
onPreview(THEME_NAMES[next]!);
|
||||
} else if (key.downArrow) {
|
||||
const next = (index + 1) % THEME_NAMES.length;
|
||||
setIndex(next);
|
||||
onPreview(THEME_NAMES[next]!);
|
||||
} else if (key.return) {
|
||||
onSubmit(THEME_NAMES[index]!);
|
||||
}
|
||||
});
|
||||
|
||||
// saved
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
|
||||
<Text bold color="green">
|
||||
▸ Saved.
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={theme.tone.brand} paddingX={1}>
|
||||
<Text bold color={theme.tone.brand}>
|
||||
{t("wizard.themeTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Run `reasonix` any time to start chatting — your settings are remembered.</Text>
|
||||
<Text dimColor>{t("wizard.themeSubtitle")}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{THEME_NAMES.map((name, i) => (
|
||||
<Box key={name}>
|
||||
<Text color={i === index ? theme.tone.brand : undefined}>
|
||||
{i === index ? "▸ " : " "}
|
||||
</Text>
|
||||
<Text bold={i === index} color={i === index ? theme.fg.strong : theme.fg.body}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text color={theme.fg.meta}>{" — "}</Text>
|
||||
<Text color={theme.fg.meta}>{t(`wizard.themeCaption.${name}`)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.fg.faint}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={theme.fg.meta}>{t("wizard.themeSampleHeading")}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.tone.accent}>{"◆ "}</Text>
|
||||
<Text color={theme.tone.accent}>Reasoning</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.tone.info}>{"▣ "}</Text>
|
||||
<Text color={theme.fg.body}>{"fs.readFile("}</Text>
|
||||
<Text color={theme.tone.ok}>{'"main.ts"'}</Text>
|
||||
<Text color={theme.fg.body}>{")"}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.fg.meta}>~/project/main.ts:42</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.tone.ok}>ok</Text>
|
||||
<Text color={theme.fg.faint}>{" · "}</Text>
|
||||
<Text color={theme.tone.warn}>warn</Text>
|
||||
<Text color={theme.fg.faint}>{" · "}</Text>
|
||||
<Text color={theme.tone.err}>err</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[Enter] to exit</Text>
|
||||
<Text dimColor>{t("wizard.themeFooter")}</Text>
|
||||
</Box>
|
||||
<ExitOnEnter onExit={exit} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- step components ----------
|
||||
|
||||
function LanguageStep({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialValue: LanguageCode;
|
||||
onSubmit: (lang: LanguageCode) => void;
|
||||
}) {
|
||||
const items: SelectItem<LanguageCode>[] = getSupportedLanguages().map((code) => ({
|
||||
value: code,
|
||||
label: LANGUAGE_LABELS[code],
|
||||
hint: code === detectSystemLanguage() ? "(detected)" : undefined,
|
||||
}));
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
{t("wizard.languageTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.languageSubtitle")}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<SingleSelect<LanguageCode>
|
||||
items={items}
|
||||
initialValue={initialValue}
|
||||
onSubmit={onSubmit}
|
||||
footer={t("wizard.selectFooter")}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyStep({
|
||||
onSubmit,
|
||||
error,
|
||||
@@ -235,16 +418,16 @@ function ApiKeyStep({
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
Welcome to Reasonix.
|
||||
{t("wizard.welcomeTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Paste your DeepSeek API key to get started.</Text>
|
||||
<Text>{t("wizard.apiKeyPrompt")}</Text>
|
||||
</Box>
|
||||
<Text dimColor>Get one at: https://platform.deepseek.com/api_keys</Text>
|
||||
<Text dimColor>Saved locally to {defaultConfigPath()}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeyGetOne")}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeySavedLocally", { path: defaultConfigPath() })}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text bold color="cyan">
|
||||
{"key › "}
|
||||
{t("wizard.apiKeyInputLabel")}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
@@ -252,7 +435,7 @@ function ApiKeyStep({
|
||||
onSubmit={(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!isPlausibleKey(trimmed)) {
|
||||
onError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
|
||||
onError(t("wizard.apiKeyInvalid"));
|
||||
setValue("");
|
||||
return;
|
||||
}
|
||||
@@ -268,7 +451,7 @@ function ApiKeyStep({
|
||||
</Box>
|
||||
) : value ? (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>preview: {redactKey(value)}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeyPreview", { redacted: redactKey(value) })}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -287,8 +470,54 @@ function McpArgsStep({
|
||||
onError: (e: string | null) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [pendingCreate, setPendingCreate] = useState<string | null>(null);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (!pendingCreate) return;
|
||||
const ch = input.toLowerCase();
|
||||
if (ch === "y" || key.return) {
|
||||
try {
|
||||
mkdirSync(pendingCreate, { recursive: true });
|
||||
const created = pendingCreate;
|
||||
setPendingCreate(null);
|
||||
setValue("");
|
||||
onError(null);
|
||||
onSubmit(created);
|
||||
} catch (e) {
|
||||
onError(
|
||||
t("wizard.mcpArgsDirCreateFailed", {
|
||||
path: pendingCreate,
|
||||
message: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
setPendingCreate(null);
|
||||
}
|
||||
} else if (ch === "n" || key.escape) {
|
||||
setPendingCreate(null);
|
||||
onError(null);
|
||||
}
|
||||
});
|
||||
|
||||
if (pendingCreate) {
|
||||
return (
|
||||
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<Text>{t("wizard.mcpArgsDirMissing", { path: pendingCreate })}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.mcpArgsDirCreateHint")}</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</StepFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StepFrame title={`Configure ${entry.name}`} step={2} total={3}>
|
||||
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<Text>{entry.summary}</Text>
|
||||
{entry.note ? (
|
||||
@@ -297,7 +526,7 @@ function McpArgsStep({
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text>Required parameter: </Text>
|
||||
<Text>{t("wizard.mcpArgsRequiredParam")}</Text>
|
||||
<Text bold>{entry.userArgs}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
@@ -311,9 +540,20 @@ function McpArgsStep({
|
||||
onSubmit={(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
onError(`${entry.name} needs a value — got an empty string.`);
|
||||
onError(t("wizard.mcpArgsEmpty", { name: entry.name }));
|
||||
return;
|
||||
}
|
||||
if (entry.name === "filesystem") {
|
||||
const check = checkFilesystemPath(trimmed);
|
||||
if (check.kind === "missing") {
|
||||
setPendingCreate(trimmed);
|
||||
return;
|
||||
}
|
||||
if (check.kind === "not-a-dir") {
|
||||
onError(t("wizard.mcpArgsNotADir", { path: trimmed }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSubmit(trimmed);
|
||||
setValue("");
|
||||
}}
|
||||
@@ -330,6 +570,14 @@ function McpArgsStep({
|
||||
);
|
||||
}
|
||||
|
||||
function checkFilesystemPath(p: string): { kind: "ok" | "missing" | "not-a-dir" } {
|
||||
try {
|
||||
return { kind: statSync(p).isDirectory() ? "ok" : "not-a-dir" };
|
||||
} catch {
|
||||
return { kind: "missing" };
|
||||
}
|
||||
}
|
||||
|
||||
function ReviewConfirm({ onConfirm }: { onConfirm: () => void }) {
|
||||
useInput((_i, key) => {
|
||||
if (key.return) onConfirm();
|
||||
@@ -344,8 +592,6 @@ function ExitOnEnter({ onExit }: { onExit: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- small bits ----------
|
||||
|
||||
function StepFrame({
|
||||
title,
|
||||
step,
|
||||
@@ -360,9 +606,7 @@ function StepFrame({
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Step {step}/{total} ·{" "}
|
||||
</Text>
|
||||
<Text dimColor>{t("wizard.stepCounter", { step, total })}</Text>
|
||||
<Text bold color="cyan">
|
||||
{title}
|
||||
</Text>
|
||||
@@ -383,8 +627,6 @@ function SummaryLine({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- data helpers ----------
|
||||
|
||||
function presetItems(): SelectItem<PresetName>[] {
|
||||
return (["auto", "flash", "pro"] as const).map((name) => ({
|
||||
value: name as PresetName,
|
||||
@@ -396,7 +638,7 @@ function presetItems(): SelectItem<PresetName>[] {
|
||||
function mcpItems(): SelectItem<string>[] {
|
||||
return MCP_CATALOG.map((entry) => {
|
||||
const hintParts: string[] = [entry.summary];
|
||||
if (entry.userArgs) hintParts.push(`(you'll provide ${entry.userArgs})`);
|
||||
if (entry.userArgs) hintParts.push(t("wizard.mcpUserArgsHint", { arg: entry.userArgs }));
|
||||
if (entry.note) hintParts.push(entry.note);
|
||||
return {
|
||||
value: entry.name,
|
||||
@@ -413,9 +655,6 @@ function placeholderFor(entry: CatalogEntry): string {
|
||||
}
|
||||
|
||||
function deriveInitialCatalog(existingSpecs: string[]): string[] {
|
||||
// Best-effort recovery: if the user previously picked catalog entries,
|
||||
// the spec strings look like `name=npx -y <pkg> ...`. Match by package
|
||||
// so reconfigure pre-checks the same boxes.
|
||||
const packageToName = new Map(MCP_CATALOG.map((e) => [e.package, e.name]));
|
||||
const out: string[] = [];
|
||||
for (const spec of existingSpecs) {
|
||||
@@ -436,12 +675,15 @@ function deriveInitialCatalog(existingSpecs: string[]): string[] {
|
||||
*/
|
||||
export function buildSpec(name: string, argsByName: Record<string, string>): string {
|
||||
const entry = CATALOG_BY_NAME.get(name);
|
||||
if (!entry) return name; // shouldn't happen; fall back gracefully
|
||||
if (!entry) return name;
|
||||
const userArg = entry.userArgs ? argsByName[name] : undefined;
|
||||
const tail = userArg ? ` ${quoteIfNeeded(userArg)}` : "";
|
||||
return `${entry.name}=npx -y ${entry.package}${tail}`;
|
||||
}
|
||||
|
||||
function quoteIfNeeded(s: string): string {
|
||||
return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
||||
// Escape backslashes BEFORE quotes — otherwise a trailing `\` in the
|
||||
// input would consume the closing quote when a downstream parser
|
||||
// un-escapes the output (CodeQL js/incomplete-sanitization).
|
||||
return /\s|"/.test(s) ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/** Alt-screen lifecycle — restore must be idempotent and run on SIGINT/SIGTERM/exit so the user's main buffer always comes back. */
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
function isInteractiveTty(): boolean {
|
||||
return Boolean(process.stdout?.isTTY);
|
||||
}
|
||||
|
||||
export function useAltScreen(): void {
|
||||
useEffect(() => {
|
||||
if (!isInteractiveTty()) return;
|
||||
// 1002 = button-event tracking (press + drag + release); 1006 = SGR coords.
|
||||
// Shift+drag bypasses tracking for native terminal selection.
|
||||
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H\x1b[?1002h\x1b[?1006h");
|
||||
|
||||
let restored = false;
|
||||
const restore = () => {
|
||||
if (restored) return;
|
||||
restored = true;
|
||||
try {
|
||||
process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?1049l");
|
||||
} catch {
|
||||
/* terminal closed */
|
||||
}
|
||||
};
|
||||
|
||||
process.once("exit", restore);
|
||||
process.once("SIGINT", () => {
|
||||
restore();
|
||||
process.exit(130);
|
||||
});
|
||||
process.once("SIGTERM", () => {
|
||||
restore();
|
||||
process.exit(143);
|
||||
});
|
||||
|
||||
return restore;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { Card } 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";
|
||||
|
||||
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.branch.color;
|
||||
return (
|
||||
<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}>{"█".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 ? (
|
||||
<Text color={FG.faint}>
|
||||
{`latest · #${card.latestIndex} · T=${card.latestTemperature.toFixed(2)} · ${card.latestUncertainties} unc`}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import type { Card } from "../state/cards.js";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
import { BranchCard } from "./BranchCard.js";
|
||||
import { CtxCard } from "./CtxCard.js";
|
||||
import { DiffCard } from "./DiffCard.js";
|
||||
import { DoctorCard } from "./DoctorCard.js";
|
||||
@@ -16,16 +14,20 @@ import { SearchCard } from "./SearchCard.js";
|
||||
import { StreamingCard } from "./StreamingCard.js";
|
||||
import { SubAgentCard } from "./SubAgentCard.js";
|
||||
import { TaskCard } from "./TaskCard.js";
|
||||
import { TipCard } from "./TipCard.js";
|
||||
import { ToolCard } from "./ToolCard.js";
|
||||
import { UsageCard } from "./UsageCard.js";
|
||||
import { UserCard } from "./UserCard.js";
|
||||
import { WarnCard } from "./WarnCard.js";
|
||||
|
||||
export function CardRenderer({ card }: { card: Card }): React.ReactElement {
|
||||
// No marginTop here — Card primitive owns inter-card spacing. Doubling it
|
||||
// bakes 2 blank rows into every static-emitted block of bytes.
|
||||
// Memoized so the cards array re-rendering (every store update) only
|
||||
// reconciles cards whose object identity actually changed — the reducer
|
||||
// keeps prior cards reference-stable, so unchanged history skips work.
|
||||
export const CardRenderer = React.memo(function CardRenderer({
|
||||
card,
|
||||
}: { card: Card }): React.ReactElement {
|
||||
return <Box flexDirection="column">{renderCard(card)}</Box>;
|
||||
}
|
||||
});
|
||||
|
||||
function renderCard(card: Card): React.ReactElement {
|
||||
switch (card.kind) {
|
||||
@@ -57,12 +59,12 @@ function renderCard(card: Card): React.ReactElement {
|
||||
return <SearchCard card={card} />;
|
||||
case "live":
|
||||
return <LiveCard card={card} />;
|
||||
case "tip":
|
||||
return <TipCard card={card} />;
|
||||
case "ctx":
|
||||
return <CtxCard card={card} />;
|
||||
case "doctor":
|
||||
return <DoctorCard card={card} />;
|
||||
case "branch":
|
||||
return <BranchCard card={card} />;
|
||||
default:
|
||||
return <FallbackCard card={card} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { CtxCard as CtxCardData } from "../state/cards.js";
|
||||
@@ -33,22 +34,24 @@ export function CtxCard({ card }: { card: CtxCardData }): React.ReactElement {
|
||||
<CardHeader
|
||||
glyph="⌘"
|
||||
tone={TONE.brand}
|
||||
title="context"
|
||||
title={t("cardTitles.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)}
|
||||
{row(t("cardLabels.system"), card.systemTokens, card.systemTokens / cap, TONE.brand)}
|
||||
{row(t("cardLabels.tools"), card.toolsTokens, card.toolsTokens / cap, TONE.warn)}
|
||||
{row(t("cardLabels.log"), card.logTokens, card.logTokens / cap, TONE.ok)}
|
||||
{row(t("cardLabels.input"), card.inputTokens, card.inputTokens / cap, TONE.accent)}
|
||||
{card.topTools.length > 0 ? (
|
||||
<>
|
||||
<Text color={FG.faint}>
|
||||
{`top tools · ${card.toolsCount} total · ${card.logMessages} log msgs`}
|
||||
{`${t("cardLabels.topTools")} · ${card.toolsCount} ${t("cardLabels.tools")} · ${card.logMessages} ${t("cardLabels.logMsgs")}`}
|
||||
</Text>
|
||||
{card.topTools.slice(0, 5).map((t) => (
|
||||
<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>
|
||||
{card.topTools.slice(0, 5).map((tool) => (
|
||||
<Box key={`${tool.turn}-${tool.name}`} flexDirection="row" gap={1}>
|
||||
<Text color={FG.sub}>{tool.name}</Text>
|
||||
<Text
|
||||
color={FG.faint}
|
||||
>{`· ${t("cardLabels.turn")} ${tool.turn} · ${tool.tokens.toLocaleString()}`}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -4,13 +4,8 @@ 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";
|
||||
|
||||
const LEVEL_COLOR: Record<DoctorCheckEntry["level"], string> = {
|
||||
ok: TONE.ok,
|
||||
warn: TONE.warn,
|
||||
fail: TONE.err,
|
||||
};
|
||||
import { useThemeTokens } from "../theme/context.js";
|
||||
import { CARD } from "../theme/tokens.js";
|
||||
|
||||
const LEVEL_GLYPH: Record<DoctorCheckEntry["level"], string> = {
|
||||
ok: "✓",
|
||||
@@ -25,6 +20,12 @@ const LEVEL_TAG: Record<DoctorCheckEntry["level"], string> = {
|
||||
};
|
||||
|
||||
export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactElement {
|
||||
const { fg, tone } = useThemeTokens();
|
||||
const levelColor: Record<DoctorCheckEntry["level"], string> = {
|
||||
ok: tone.ok,
|
||||
warn: tone.warn,
|
||||
fail: tone.err,
|
||||
};
|
||||
const ok = card.checks.filter((c) => c.level === "ok").length;
|
||||
const warn = card.checks.filter((c) => c.level === "warn").length;
|
||||
const fail = card.checks.filter((c) => c.level === "fail").length;
|
||||
@@ -36,12 +37,12 @@ export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactEleme
|
||||
<CardHeader glyph="⚕" tone={CARD.tool.color} title="doctor" meta={[summary]} />
|
||||
{card.checks.map((c) => (
|
||||
<Box key={c.label} flexDirection="row" gap={1}>
|
||||
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_GLYPH[c.level]}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
<Text color={levelColor[c.level]}>{LEVEL_GLYPH[c.level]}</Text>
|
||||
<Text bold color={fg.body}>
|
||||
{c.label.padEnd(labelWidth + 1)}
|
||||
</Text>
|
||||
<Text color={FG.sub}>{c.detail}</Text>
|
||||
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_TAG[c.level]}</Text>
|
||||
<Text color={fg.sub}>{c.detail}</Text>
|
||||
<Text color={levelColor[c.level]}>{LEVEL_TAG[c.level]}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { ErrorCard as ErrorCardData } from "../state/cards.js";
|
||||
@@ -11,7 +12,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} ${t("cardLabels.retries")}`
|
||||
: null;
|
||||
const stackLines = card.stack ? card.stack.split("\n") : [];
|
||||
const stackTrunc = stackLines.length > STACK_TAIL;
|
||||
@@ -25,7 +26,7 @@ export function ErrorCard({ card }: { card: ErrorCardData }): React.ReactElement
|
||||
<CardHeader
|
||||
glyph="✖"
|
||||
tone={TONE.err}
|
||||
title={card.title || "error"}
|
||||
title={card.title || t("cardTitles.error")}
|
||||
meta={retryNote ? [retryNote] : undefined}
|
||||
/>
|
||||
{messageLines.map((line, i) => (
|
||||
@@ -35,10 +36,13 @@ export function ErrorCard({ card }: { card: ErrorCardData }): React.ReactElement
|
||||
))}
|
||||
{hasStack ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={FG.meta}>stack trace</Text>
|
||||
<Text color={FG.meta}>{t("cardLabels.stackTrace")}</Text>
|
||||
{stackHidden > 0 ? (
|
||||
<Text color={FG.faint}>
|
||||
{`⋮ ${stackHidden} earlier stack line${stackHidden === 1 ? "" : "s"} hidden`}
|
||||
{t(
|
||||
stackHidden === 1 ? "cardLabels.earlierStackLine" : "cardLabels.earlierStackLines",
|
||||
{ count: stackHidden },
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
{stackVisible.map((line, i) => (
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
import { useThemeTokens } from "../theme/context.js";
|
||||
|
||||
const STATUS_GLYPH: Record<PlanStep["status"], string> = {
|
||||
queued: "○",
|
||||
@@ -15,53 +15,53 @@ const STATUS_GLYPH: Record<PlanStep["status"], string> = {
|
||||
skipped: "s",
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<PlanStep["status"], string> = {
|
||||
queued: FG.faint,
|
||||
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 { fg, tone, toneActive } = useThemeTokens();
|
||||
const statusColor: Record<PlanStep["status"], string> = {
|
||||
queued: fg.faint,
|
||||
running: toneActive.brand,
|
||||
done: tone.ok,
|
||||
failed: tone.err,
|
||||
blocked: tone.warn,
|
||||
skipped: fg.faint,
|
||||
};
|
||||
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 cardTone = hasRunning ? toneActive.accent : tone.accent;
|
||||
|
||||
const window = pickWindow(card.steps);
|
||||
|
||||
return (
|
||||
<Card tone={tone}>
|
||||
<CardHeader glyph="⊞" tone={tone} title={card.title} meta={[progress]} />
|
||||
<Card tone={cardTone}>
|
||||
<CardHeader glyph="⊞" tone={cardTone} 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>
|
||||
<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;
|
||||
const titleColor = isActive ? fg.strong : fg.sub;
|
||||
return (
|
||||
<Box key={step.id} flexDirection="row" gap={1}>
|
||||
<Text color={STATUS_COLOR[step.status]}>{STATUS_GLYPH[step.status]}</Text>
|
||||
<Text color={statusColor[step.status]}>{STATUS_GLYPH[step.status]}</Text>
|
||||
<Text bold={isActive} color={titleColor}>
|
||||
{`${step.indexLabel}. ${step.title}`}
|
||||
</Text>
|
||||
{isActive ? <Text color={TONE_ACTIVE.brand}>← in progress</Text> : null}
|
||||
{isActive ? <Text color={toneActive.brand}>← in progress</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{window.hiddenAfter > 0 ? (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.faint}>○</Text>
|
||||
<Text color={FG.faint}>{`⋯ ${window.hiddenAfter} upcoming`}</Text>
|
||||
<Text color={fg.faint}>○</Text>
|
||||
<Text color={fg.faint}>{`⋯ ${window.hiddenAfter} upcoming`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { t } from "../../../i18n/index.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader, type MetaItem } from "../primitives/CardHeader.js";
|
||||
import { CursorBlock } from "../primitives/CursorBlock.js";
|
||||
@@ -47,7 +48,11 @@ function ReasoningHeader({ card }: { card: ReasoningCardData }): React.ReactElem
|
||||
const streamingActive = card.streaming && !card.aborted;
|
||||
const headColor = card.aborted ? TONE.err : streamingActive ? TONE_ACTIVE.accent : TONE.accent;
|
||||
const glyph = streamingActive ? "◇" : "◆";
|
||||
const title = streamingActive ? "reasoning…" : card.aborted ? "reasoning (aborted)" : "reasoning";
|
||||
const title = streamingActive
|
||||
? t("cardTitles.reasoningEllipsis")
|
||||
: card.aborted
|
||||
? t("cardTitles.reasoningAborted")
|
||||
: t("cardTitles.reasoning");
|
||||
const meta: MetaItem[] = [];
|
||||
const m = headerMeta(card);
|
||||
if (m) meta.push(m);
|
||||
@@ -76,12 +81,12 @@ function ReasoningHeader({ card }: { card: ReasoningCardData }): React.ReactElem
|
||||
|
||||
function headerMeta(card: ReasoningCardData): string {
|
||||
if (card.streaming) {
|
||||
return card.tokens > 0 ? `${card.tokens.toLocaleString()} tok` : "";
|
||||
return card.tokens > 0 ? `${card.tokens.toLocaleString()} ${t("cardLabels.tok")}` : "";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (card.tokens > 0) parts.push(`${card.tokens.toLocaleString()} tok`);
|
||||
if (card.paragraphs > 0) parts.push(`${card.paragraphs} ¶`);
|
||||
return parts.join(" · ");
|
||||
if (card.tokens > 0) parts.push(`${card.tokens.toLocaleString()} ${t("cardLabels.tok")}`);
|
||||
if (card.paragraphs > 0) parts.push(`${card.paragraphs} ${t("cardLabels.pilcrow")}`);
|
||||
return parts.join(" \u00b7 ");
|
||||
}
|
||||
|
||||
function headerDuration(card: ReasoningCardData): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { SearchCard as SearchCardData, SearchHit } from "../state/cards.js";
|
||||
@@ -9,9 +10,10 @@ import { FG, TONE } from "../theme/tokens.js";
|
||||
export function SearchCard({ card }: { card: SearchCardData }): React.ReactElement {
|
||||
const fileCount = new Set(card.hits.map((h) => h.file)).size;
|
||||
const elapsed = `${(card.elapsedMs / 1000).toFixed(2)}s`;
|
||||
const stats = `${card.hits.length} hit${card.hits.length === 1 ? "" : "s"} · ${fileCount} file${
|
||||
fileCount === 1 ? "" : "s"
|
||||
}`;
|
||||
const stats = t(card.hits.length === 1 ? "cardLabels.hitSingular" : "cardLabels.hitsPlural", {
|
||||
count: card.hits.length,
|
||||
files: fileCount,
|
||||
});
|
||||
|
||||
const grouped = groupByFile(card.hits.slice(0, 10));
|
||||
|
||||
@@ -20,7 +22,7 @@ export function SearchCard({ card }: { card: SearchCardData }): React.ReactEleme
|
||||
<CardHeader
|
||||
glyph="⊙"
|
||||
tone={TONE.info}
|
||||
title="search"
|
||||
title={t("cardTitles.search")}
|
||||
subtitle={`"${card.query}"`}
|
||||
meta={[stats, elapsed]}
|
||||
/>
|
||||
@@ -38,7 +40,14 @@ export function SearchCard({ card }: { card: SearchCardData }): React.ReactEleme
|
||||
</Box>
|
||||
))}
|
||||
{card.hits.length > 10 ? (
|
||||
<Text color={FG.faint}>{`⋮ +${card.hits.length - 10} more hits`}</Text>
|
||||
<Text color={FG.faint}>
|
||||
{t(
|
||||
card.hits.length - 10 === 1
|
||||
? "cardLabels.moreHitSingular"
|
||||
: "cardLabels.moreHitsPlural",
|
||||
{ count: card.hits.length - 10 },
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import React, { useContext } from "react";
|
||||
import { clipToCells, wrapToCells } from "../../../frame/width.js";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { countTokens } from "../../../tokenizer.js";
|
||||
import { LiveExpandContext } from "../layout/LiveExpandContext.js";
|
||||
import { useReserveRows } from "../layout/viewport-budget.js";
|
||||
import { Markdown } from "../markdown.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
@@ -10,17 +13,49 @@ import { PILL_MODEL, Pill, modelBadgeFor } from "../primitives/Pill.js";
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import type { StreamingCard as StreamingCardData } from "../state/cards.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
import { useSlowTick } from "../ticker.js";
|
||||
|
||||
/** Streaming preview tail length — bounded live region so chunks don't thrash whole-card layout. */
|
||||
const STREAMING_PREVIEW_LINES = 4;
|
||||
/** Expanded mode shows up to this many lines so the card can't swallow the whole viewport. */
|
||||
const EXPANDED_MAX_LINES = 60;
|
||||
|
||||
const MIN_ELAPSED_MS_FOR_RATE = 500;
|
||||
const MIN_TOKENS_FOR_RATE = 4;
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 10000) return `${(n / 1000).toFixed(1)}k`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(2)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function tokenRate(
|
||||
text: string,
|
||||
startTs: number,
|
||||
endTs: number,
|
||||
): { tokens: number; tps: number | null } {
|
||||
const tokens = countTokens(text);
|
||||
const elapsedMs = endTs - startTs;
|
||||
if (elapsedMs < MIN_ELAPSED_MS_FOR_RATE || tokens < MIN_TOKENS_FOR_RATE) {
|
||||
return { tokens, tps: null };
|
||||
}
|
||||
return { tokens, tps: Math.round((tokens * 1000) / elapsedMs) };
|
||||
}
|
||||
|
||||
const PILL_RATE = { bg: "#11141a", fg: "#8b949e" } as const;
|
||||
|
||||
export function StreamingCard({ card }: { card: StreamingCardData }): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const expanded = useContext(LiveExpandContext);
|
||||
const reserveCap = expanded ? EXPANDED_MAX_LINES + 2 : STREAMING_PREVIEW_LINES + 2;
|
||||
useReserveRows("stream", {
|
||||
min: STREAMING_PREVIEW_LINES + 1,
|
||||
max: STREAMING_PREVIEW_LINES + 2,
|
||||
max: reserveCap,
|
||||
});
|
||||
// Re-render at 1Hz so the rate keeps updating even when chunks stall.
|
||||
// Frozen once `card.done` is true — settled cards render via Static.
|
||||
useSlowTick();
|
||||
|
||||
const modelBadge = card.model ? modelBadgeFor(card.model) : null;
|
||||
const modelPill = modelBadge ? (
|
||||
@@ -28,9 +63,24 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
|
||||
) : null;
|
||||
|
||||
if (card.done && !card.aborted) {
|
||||
const { tokens, tps } = tokenRate(card.text, card.ts, card.endedAt ?? Date.now());
|
||||
const ratePill =
|
||||
tokens >= MIN_TOKENS_FOR_RATE && tps !== null ? (
|
||||
<Pill label={`${formatTokenCount(tokens)} tok · ${tps} t/s`} {...PILL_RATE} bold={false} />
|
||||
) : null;
|
||||
return (
|
||||
<Card tone={TONE.ok}>
|
||||
<CardHeader glyph="‹" tone={TONE.ok} title="reply" right={modelPill} />
|
||||
<CardHeader
|
||||
glyph="‹"
|
||||
tone={TONE.ok}
|
||||
title={t("cardTitles.reply")}
|
||||
right={
|
||||
<>
|
||||
{ratePill}
|
||||
{modelPill}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Markdown text={card.text} />
|
||||
</Card>
|
||||
);
|
||||
@@ -39,11 +89,22 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
|
||||
const lineCells = Math.max(20, cols - 4);
|
||||
const allLines = card.text.length > 0 ? card.text.split("\n") : [""];
|
||||
const visualLines = allLines.flatMap((l) => wrapToCells(l, lineCells));
|
||||
const visible = visualLines.slice(-STREAMING_PREVIEW_LINES);
|
||||
const cap = expanded ? EXPANDED_MAX_LINES : STREAMING_PREVIEW_LINES;
|
||||
const visible = visualLines.slice(-cap);
|
||||
const droppedAbove = Math.max(0, visualLines.length - visible.length);
|
||||
const aborted = !!card.aborted;
|
||||
const headColor = aborted ? TONE.err : TONE_ACTIVE.brand;
|
||||
const glyph = aborted ? "‹" : "◈";
|
||||
const headLabel = aborted ? "aborted" : "writing…";
|
||||
const headLabel = aborted ? t("cardLabels.aborted") : t("cardLabels.writing");
|
||||
|
||||
const { tokens: liveTokens, tps: liveTps } = tokenRate(card.text, card.ts, Date.now());
|
||||
const liveRatePill =
|
||||
!aborted && liveTokens >= MIN_TOKENS_FOR_RATE && liveTps !== null ? (
|
||||
<Pill label={`${liveTps} t/s`} {...PILL_RATE} bold={false} />
|
||||
) : null;
|
||||
const expandPill = !aborted ? (
|
||||
<Pill label={expanded ? "expanded ⌃o" : "preview ⌃o"} {...PILL_RATE} bold={false} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Card tone={headColor}>
|
||||
@@ -53,17 +114,26 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
|
||||
title={headLabel}
|
||||
right={
|
||||
<>
|
||||
{liveRatePill}
|
||||
{expandPill}
|
||||
{aborted ? null : <Spinner kind="braille" color={TONE_ACTIVE.brand} />}
|
||||
{modelPill}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{expanded && droppedAbove > 0 ? (
|
||||
<Text color={FG.faint}>
|
||||
{t(droppedAbove === 1 ? "cardLabels.earlierLine" : "cardLabels.earlierLines", {
|
||||
count: droppedAbove,
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{visible.map((line, i) => (
|
||||
<Box key={`${card.id}:${allLines.length - visible.length + i}`} flexDirection="row">
|
||||
<Box key={`${card.id}:${visualLines.length - visible.length + i}`} flexDirection="row">
|
||||
<Text color={aborted ? FG.meta : FG.body}>{clipToCells(line, lineCells)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{aborted ? <Text color={FG.faint}>[truncated by esc]</Text> : null}
|
||||
{aborted ? <Text color={FG.faint}>{t("cardLabels.truncatedByEsc")}</Text> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,48 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useContext } from "react";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
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, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
const STATUS_COLOR: Record<SubAgentCardData["status"], string> = {
|
||||
running: TONE_ACTIVE.violet,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
};
|
||||
import { useThemeTokens } from "../theme/context.js";
|
||||
import { CARD } from "../theme/tokens.js";
|
||||
|
||||
export function SubAgentCard({ card }: { card: SubAgentCardData }): React.ReactElement {
|
||||
const headColor = STATUS_COLOR[card.status];
|
||||
const headGlyph = card.status === "failed" ? "✖" : "⌬";
|
||||
const { fg, tone, toneActive } = useThemeTokens();
|
||||
const statusColor: Record<SubAgentCardData["status"], string> = {
|
||||
running: toneActive.violet,
|
||||
done: tone.ok,
|
||||
failed: tone.err,
|
||||
};
|
||||
const headColor = statusColor[card.status];
|
||||
const headGlyph = card.status === "failed" ? "\u2717" : "\u232c";
|
||||
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"]
|
||||
? [`${runningChildren} ${t("cardLabels.runningLabel")}`]
|
||||
: [t("cardLabels.workingLabel")]
|
||||
: [{ text: card.status, color: headColor }];
|
||||
return (
|
||||
<CardWrap tone={headColor}>
|
||||
<CardHeader
|
||||
glyph={headGlyph}
|
||||
tone={headColor}
|
||||
title="subagent"
|
||||
titleColor={TONE.violet}
|
||||
title={t("cardTitles.subagent")}
|
||||
titleColor={tone.violet}
|
||||
subtitle={card.task}
|
||||
meta={headerMeta}
|
||||
/>
|
||||
{card.name ? <Text color={FG.faint}>{`agent · ${card.name}`}</Text> : null}
|
||||
{card.name ? <Text color={fg.faint}>{`${t("cardLabels.agent")} · ${card.name}`}</Text> : null}
|
||||
{card.tools && card.tools.length > 0 && (
|
||||
<Text color={FG.faint}>{`tools · ${card.tools.join(", ")}`}</Text>
|
||||
<Text color={fg.faint}>{`${t("cardLabels.tools")} · ${card.tools.join(", ")}`}</Text>
|
||||
)}
|
||||
{card.children.map((child) => (
|
||||
<Box key={child.id} flexDirection="row" gap={1}>
|
||||
{inLive ? null : <Text color={TONE.violet}>▎</Text>}
|
||||
{inLive ? null : <Text color={tone.violet}>▎</Text>}
|
||||
<ChildRow card={child} />
|
||||
</Box>
|
||||
))}
|
||||
@@ -68,13 +70,14 @@ interface ChildVisual {
|
||||
}
|
||||
|
||||
function ChildRow({ card }: { card: Card }): React.ReactElement {
|
||||
const v = childVisual(card);
|
||||
const { fg, tone } = useThemeTokens();
|
||||
const v = childVisual(card, tone.ok, tone.err, fg.faint);
|
||||
const isDone = isChildDone(card);
|
||||
return (
|
||||
<>
|
||||
{v.statusGlyph}
|
||||
<Text color={v.kindColor}>{v.kindGlyph}</Text>
|
||||
<Text dimColor={isDone} color={FG.body}>
|
||||
<Text dimColor={isDone} color={fg.body}>
|
||||
{v.text}
|
||||
</Text>
|
||||
</>
|
||||
@@ -89,25 +92,30 @@ function doneGlyph(color: string): React.ReactElement {
|
||||
return <Text color={color}>✓</Text>;
|
||||
}
|
||||
|
||||
function failedGlyph(): React.ReactElement {
|
||||
return <Text color={TONE.err}>✖</Text>;
|
||||
function failedGlyph(color: string): React.ReactElement {
|
||||
return <Text color={color}>✖</Text>;
|
||||
}
|
||||
|
||||
function childVisual(card: Card): ChildVisual {
|
||||
function childVisual(
|
||||
card: Card,
|
||||
doneColor: string,
|
||||
failedColor: string,
|
||||
fallbackColor: string,
|
||||
): ChildVisual {
|
||||
switch (card.kind) {
|
||||
case "reasoning": {
|
||||
const done = !card.streaming;
|
||||
return {
|
||||
statusGlyph: done ? doneGlyph(TONE.ok) : runningGlyph(CARD.reasoning.color),
|
||||
statusGlyph: done ? doneGlyph(doneColor) : runningGlyph(CARD.reasoning.color),
|
||||
kindGlyph: "◆",
|
||||
kindColor: CARD.reasoning.color,
|
||||
text: `reasoning · ${card.paragraphs} ¶`,
|
||||
text: t("cardLabels.reasoningLabel", { count: 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),
|
||||
statusGlyph: card.done ? doneGlyph(doneColor) : runningGlyph(CARD.tool.color),
|
||||
kindGlyph: "▣",
|
||||
kindColor: CARD.tool.color,
|
||||
text: `${card.name}${elapsed}`,
|
||||
@@ -115,30 +123,30 @@ function childVisual(card: Card): ChildVisual {
|
||||
}
|
||||
case "streaming":
|
||||
return {
|
||||
statusGlyph: card.done ? doneGlyph(TONE.ok) : runningGlyph(CARD.streaming.color),
|
||||
statusGlyph: card.done ? doneGlyph(doneColor) : runningGlyph(CARD.streaming.color),
|
||||
kindGlyph: "◈",
|
||||
kindColor: CARD.streaming.color,
|
||||
text: card.done ? "response" : "writing …",
|
||||
text: card.done ? t("cardLabels.response") : t("cardLabels.writing"),
|
||||
};
|
||||
case "diff":
|
||||
return {
|
||||
statusGlyph: doneGlyph(TONE.ok),
|
||||
statusGlyph: doneGlyph(doneColor),
|
||||
kindGlyph: "±",
|
||||
kindColor: CARD.diff.color,
|
||||
text: card.file,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
statusGlyph: failedGlyph(),
|
||||
statusGlyph: failedGlyph(failedColor),
|
||||
kindGlyph: "✖",
|
||||
kindColor: CARD.error.color,
|
||||
text: card.title,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
statusGlyph: <Text color={FG.faint}>·</Text>,
|
||||
statusGlyph: <Text color={fallbackColor}>·</Text>,
|
||||
kindGlyph: "·",
|
||||
kindColor: FG.faint,
|
||||
kindColor: fallbackColor,
|
||||
text: card.kind,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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";
|
||||
import { useThemeTokens } from "../theme/context.js";
|
||||
|
||||
const STEP_GLYPH: Record<TaskStep["status"], string> = {
|
||||
queued: "○",
|
||||
@@ -13,19 +13,6 @@ const STEP_GLYPH: Record<TaskStep["status"], string> = {
|
||||
failed: "✗",
|
||||
};
|
||||
|
||||
const STEP_COLOR: Record<TaskStep["status"], string> = {
|
||||
queued: FG.faint,
|
||||
running: TONE.warn,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
};
|
||||
|
||||
const TASK_COLOR: Record<TaskCardData["status"], string> = {
|
||||
running: TONE.warn,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
};
|
||||
|
||||
const TASK_GLYPH: Record<TaskCardData["status"], string> = {
|
||||
running: "▶",
|
||||
done: "✓",
|
||||
@@ -33,26 +20,38 @@ const TASK_GLYPH: Record<TaskCardData["status"], string> = {
|
||||
};
|
||||
|
||||
export function TaskCard({ card }: { card: TaskCardData }): React.ReactElement {
|
||||
const { fg, tone } = useThemeTokens();
|
||||
const stepColor: Record<TaskStep["status"], string> = {
|
||||
queued: fg.faint,
|
||||
running: tone.warn,
|
||||
done: tone.ok,
|
||||
failed: tone.err,
|
||||
};
|
||||
const taskColor: Record<TaskCardData["status"], string> = {
|
||||
running: tone.warn,
|
||||
done: tone.ok,
|
||||
failed: tone.err,
|
||||
};
|
||||
const elapsed = `${(card.elapsedMs / 1000).toFixed(1)}s`;
|
||||
return (
|
||||
<Card tone={TASK_COLOR[card.status]}>
|
||||
<Card tone={taskColor[card.status]}>
|
||||
<CardHeader
|
||||
glyph={TASK_GLYPH[card.status]}
|
||||
tone={TASK_COLOR[card.status]}
|
||||
tone={taskColor[card.status]}
|
||||
title={`step ${card.index}/${card.total}`}
|
||||
subtitle={card.title}
|
||||
meta={[elapsed, card.status]}
|
||||
/>
|
||||
{card.steps.map((step) => (
|
||||
<Box key={step.id} flexDirection="row" gap={1}>
|
||||
<Text color={STEP_COLOR[step.status]}>{STEP_GLYPH[step.status]}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
<Text color={stepColor[step.status]}>{STEP_GLYPH[step.status]}</Text>
|
||||
<Text bold color={fg.body}>
|
||||
{(step.toolName ?? "step").padEnd(7)}
|
||||
</Text>
|
||||
<Text color={FG.sub}>{step.title}</Text>
|
||||
{step.detail ? <Text color={FG.faint}>{step.detail}</Text> : null}
|
||||
<Text color={fg.sub}>{step.title}</Text>
|
||||
{step.detail ? <Text color={fg.faint}>{step.detail}</Text> : null}
|
||||
{step.elapsedMs !== undefined ? (
|
||||
<Text color={FG.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
|
||||
<Text color={fg.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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 stringWidth from "string-width";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import type { TipCard as TipCardData, TipRow as TipRowData } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
const KEY_GUTTER = 4;
|
||||
|
||||
export function TipCard({ card }: { card: TipCardData }): React.ReactElement {
|
||||
const keyWidth = card.sections.reduce(
|
||||
(max, sec) => sec.rows.reduce((m, r) => Math.max(m, stringWidth(r.key)), max),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2} marginY={1}>
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.accent} bold>
|
||||
ⓘ
|
||||
</Text>
|
||||
<Text color={FG.body} bold>
|
||||
{card.topic}
|
||||
</Text>
|
||||
</Box>
|
||||
{card.oneTime ? <Text color={FG.faint}>{t("ui.tipShownOnce")}</Text> : null}
|
||||
</Box>
|
||||
{card.sections.map((section, i) => (
|
||||
<Box key={section.title ?? `section-${i}`} flexDirection="column" marginTop={1}>
|
||||
{section.title ? (
|
||||
<Box marginBottom={0}>
|
||||
<Text color={FG.sub}>{section.title}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{section.rows.map((row) => (
|
||||
<TipRowRender
|
||||
key={row.key}
|
||||
row={row}
|
||||
keyWidth={keyWidth}
|
||||
indent={section.title ? 2 : 0}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{card.footer ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>{card.footer}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TipRowRender({
|
||||
row,
|
||||
keyWidth,
|
||||
indent,
|
||||
}: {
|
||||
row: TipRowData;
|
||||
keyWidth: number;
|
||||
indent: number;
|
||||
}) {
|
||||
const pad = " ".repeat(Math.max(0, keyWidth - stringWidth(row.key) + KEY_GUTTER));
|
||||
const lead = indent > 0 ? " ".repeat(indent) : "";
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{lead ? <Text>{lead}</Text> : null}
|
||||
<Text color={TONE.accent}>{row.key}</Text>
|
||||
<Text>{pad}</Text>
|
||||
<Text color={FG.body}>{row.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user