mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9a1d0912d | ||
|
|
3f3b5e2fe7 | ||
|
|
0990491584 | ||
|
|
40628d0025 | ||
|
|
346bb35cb8 | ||
|
|
97a93cf4f5 | ||
|
|
d837ddaf43 | ||
|
|
c56cd9d9a0 | ||
|
|
db194cb833 | ||
|
|
d817f1b965 | ||
|
|
9ea76a3ef9 | ||
|
|
136ed9ff58 | ||
|
|
5dbcd1b73b | ||
|
|
447d1d7049 | ||
|
|
042d2c4f6a | ||
|
|
f8143f7098 | ||
|
|
6fcf3a6cba | ||
|
|
1934e2598c | ||
|
|
03804e6170 | ||
|
|
b3f0aee657 | ||
|
|
1ffa27975c | ||
|
|
d63509ad85 | ||
|
|
459a370b7c | ||
|
|
39e69c2cab | ||
|
|
0d89ab84ef | ||
|
|
ecb1bfb051 | ||
|
|
db2ca97eb0 | ||
|
|
47f207119f | ||
|
|
6ccca2be0b |
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,12 +18,13 @@ What you expected to happen.
|
||||
Steps or minimal code that reproduces it.
|
||||
|
||||
**Environment**
|
||||
- Reasonix version:
|
||||
- Node version:
|
||||
- OS:
|
||||
- Shell (bash, zsh, fish, PowerShell, etc.):
|
||||
- Terminal emulator (kitty, WezTerm, Windows Terminal, etc.):
|
||||
- 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.
|
||||
+121
@@ -3,6 +3,127 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.31.0] — 2026-05-08
|
||||
|
||||
**Headline:** a Mac user reported a DeepSeek 503 day where Reasonix
|
||||
showed a wall of raw `DeepSeek 503: <html>...` and they couldn't tell
|
||||
if our agent had crashed or the upstream API was down. Two threads of
|
||||
work fell out of that single bug: a friendly outage notice with a
|
||||
1.5s reachability probe to `/user/balance` (so we can say "DS main
|
||||
API answered, but /chat/completions is failing — their problem") and
|
||||
a full sweep of every hardcoded English string a Chinese user could
|
||||
hit. ~150 strings across 8 files moved into the `loop.*` / `errors.*`
|
||||
/ `app.*` / `hooks.*` / `summary.*` / `wizard.*` namespaces with
|
||||
zh-CN translations. The setup wizard now opens with a language picker
|
||||
defaulting to `detectSystemLanguage()` — for the case where
|
||||
`Intl.DateTimeFormat().resolvedOptions().locale` returns the wrong
|
||||
locale and a user shouldn't have to discover `/language` after
|
||||
finishing setup in English.
|
||||
|
||||
The other half of the release is dashboard parity work — picker
|
||||
modals (sessions / checkpoint / MCP marketplace), viewer modal for
|
||||
`/replay`, plus cockpit / budget gauge / model picker / loop control
|
||||
panel / `/pro` one-shot — closing buckets B-E of the #369 web-parity
|
||||
tracker.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(loop): friendly DeepSeek 5xx error with reachability probe.
|
||||
When the chat endpoint returns 5xx (after retry.ts has already
|
||||
retried 4× with backoff), `formatLoopError` now spawns a 1.5s
|
||||
`/user/balance` probe and renders one of three messages: no probe
|
||||
(generic outage notice), reachable (main API up but /chat dying),
|
||||
unreachable (DS or your network is down). All three say "this is
|
||||
a DeepSeek-side problem, not Reasonix" and link
|
||||
https://status.deepseek.com. Removes the misleading file header in
|
||||
`loop/errors.ts` that claimed retry.ts swallowed all 5xx — it
|
||||
doesn't, and never did. (#440)
|
||||
- feat(wizard): first-launch language picker + full i18n. New
|
||||
`language` step before `apiKey`, cursor defaults to
|
||||
`detectSystemLanguage()` marked `(detected)`. Selection saves
|
||||
immediately so all later wizard screens render in the chosen
|
||||
language. Re-running `reasonix setup` opens at the same step with
|
||||
the cursor on the saved language so Enter is a no-op. The wizard's
|
||||
~30 hardcoded strings (welcome, prompts, validation errors, MCP
|
||||
catalog hints, review labels, save errors, saved screen) all moved
|
||||
to a new `wizard.*` namespace with zh-CN. (#442)
|
||||
- feat(dashboard): picker modal protocol for web parity. New
|
||||
`picker` modal kind drives sessions, checkpoint, and MCP
|
||||
marketplace pickers from the same protocol. Closes the gap where
|
||||
TUI-only modals stayed inaccessible from `/dashboard`. (#417,
|
||||
#418, #419, #420)
|
||||
- feat(dashboard): viewer modal kind for `/replay`. Loads an archived
|
||||
plan into a read-only time-travel snapshot, mirrors the TUI replay
|
||||
experience. (#421)
|
||||
- feat(dashboard): cockpit tile + budget gauge + 14-day cost trend.
|
||||
At-a-glance current-session telemetry on the overview panel. (#431)
|
||||
- feat(dashboard): editable model picker in settings + `/pro`
|
||||
one-shot panel + loop control (start / stop / countdown). The
|
||||
settings tab is now the single place to flip model preset, arm
|
||||
`/pro` for the next turn, or start an autonomous loop without
|
||||
switching to the TUI. (#430, #432, #433)
|
||||
- feat(dashboard): server surface for `/pro` / `/budget` / `/model`
|
||||
/ `/loop`. POST endpoints under `/api/cockpit/*` carry the
|
||||
mutations the panels above need. (#429)
|
||||
|
||||
**i18n sweep:**
|
||||
|
||||
- i18n(loop/errors): localize DeepSeek error messages — context
|
||||
overflow, 401/402/422/400, 5xx (with the new reachability probe
|
||||
variants), reason prefixes for budget/aborted/context-guard/stuck.
|
||||
20 keys + zh-CN. (#444)
|
||||
- i18n(loop): 14 user-facing yields in `step()` — budget exhausted /
|
||||
80% warning, /pro armed, aborted-at-iter, tool-budget warning,
|
||||
preflight fold/no-fold, flash + auto escalation, storm-broken,
|
||||
history compaction (regular + aggressive), forcing-summary. The
|
||||
`loop.*` namespace had 7 dead keys defined but never wired —
|
||||
removed and replaced with 20 that match the actual yield shapes.
|
||||
(#445)
|
||||
- i18n(hooks/summary): hook outcome formatter (`hook PreToolUse/Bash
|
||||
\`cmd\` block (output truncated at 256KB)`) and the force-summary
|
||||
status / hallucinated-fallback / failed-fallback strings now go
|
||||
through `t()`. New `hooks.*` and `summary.*` namespaces. (#446)
|
||||
- i18n(app): ~26 hardcoded strings in `App.tsx` plus seven existing
|
||||
`ui.*` keys that had been declared but never called (same dead-key
|
||||
pattern as `loop.*`). New `app.*` namespace covers walk modal,
|
||||
edit-mode cycle (review/auto/yolo), edit gate, dashboard stopped,
|
||||
hash-memory note, bash-mode failures, hook header rows,
|
||||
@mentions / @url, shell confirm, checkpoint saved, plan
|
||||
continue / stop / revise. (#447)
|
||||
- i18n(slash): four lagging slash handlers — `web-search-engine.ts`
|
||||
was 0% localized, plus `mcp.ts` / `plans.ts` / `semantic.ts` had
|
||||
small gaps. ~22 new keys. (#448)
|
||||
- i18n(dashboard): translate the plan `idle` status pill — the
|
||||
`active` / `done` pills already used `t()` but the third branch
|
||||
was hardcoded English. (#443)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(search): honor abort during recursive fs scans — Esc during a
|
||||
large `search` tool call now exits promptly instead of finishing
|
||||
the walk. (#400)
|
||||
- fix(ui): refresh model badge on dashboard preset change and /pro
|
||||
turns — the header pill stayed stale across server-side
|
||||
preset/pro switches. (#403)
|
||||
- fix(permissions): match Windows project keys case-insensitively —
|
||||
the project allowlist hashed `C:\Foo` differently from `c:\foo`,
|
||||
causing entries to "disappear" depending on which case the cwd
|
||||
carried. (#402)
|
||||
- fix(prompt): inline short single-line pastes verbatim — the long-
|
||||
paste collapser was firing on tiny one-liners and burying them
|
||||
behind a "(N chars pasted)" placeholder. (#397)
|
||||
|
||||
**Tests / refactor / docs:**
|
||||
|
||||
- test(mcp): cover startup summary states (#396)
|
||||
- chore: improve loop.ts tests (#271)
|
||||
- refactor(ui): quiet chat-screen chrome — fewer always-on rows on
|
||||
the welcome card so the prompt stays close to the top. (#411)
|
||||
- docs(readme): canonical install + subcommand cheatsheet (#408)
|
||||
- docs(issues): split off display/rendering template, collect
|
||||
terminal host info inline. (#412)
|
||||
- docs(dashboard): PARITY.md audit — bucket E of #369. (#439)
|
||||
|
||||
## [0.30.5] — 2026-05-07
|
||||
|
||||
**Headline:** three contributor-led follow-ups from the #350 RFC plus
|
||||
|
||||
@@ -98,6 +98,20 @@ npx reasonix code # paste a DeepSeek API key on first run; persists after
|
||||
|
||||
Requires Node ≥ 22. Tested on macOS · Linux · Windows (PowerShell · Git Bash · Windows Terminal). Get a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
|
||||
|
||||
`npx` is the recommended path — no global install, always picks up the latest version. If you'll use Reasonix daily and want `reasonix` on your `PATH`, run `reasonix update` once and it'll do the `npm install -g` for you.
|
||||
|
||||
### Subcommand cheatsheet
|
||||
|
||||
| Command | When to use |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | Coding agent rooted at a project. **Start here.** |
|
||||
| `reasonix chat` | Plain chat — no filesystem tools, just a conversation with persisted history. |
|
||||
| `reasonix run "task"` | One-shot, streams the answer to stdout. Good for shell pipes. |
|
||||
| `reasonix doctor` | Environment health check (Node version, API key, MCP wiring). |
|
||||
| `reasonix update` | Upgrade Reasonix itself. |
|
||||
|
||||
Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`) are listed in `reasonix --help` and on the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
|
||||
|
||||
**Working in a different folder:** Reasonix scopes filesystem tools to the launch directory. To work elsewhere, pass `--dir`:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -52,6 +52,20 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
|
||||
要求 Node ≥ 22。已在 macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)测过。[去拿 DeepSeek API Key →](https://platform.deepseek.com/api_keys) · 完整 flag 看 `reasonix code --help`。
|
||||
|
||||
`npx` 是推荐路径 —— 不用全局安装,每次都拿到最新版本。如果你天天用、想把 `reasonix` 装到 `PATH` 上,跑一次 `reasonix update` 就行,它会替你跑 `npm install -g`。
|
||||
|
||||
### 子命令速查
|
||||
|
||||
| 命令 | 适用场景 |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | 锁在某个项目根目录的编码 agent。**先用这个。** |
|
||||
| `reasonix chat` | 纯聊天 —— 不挂文件系统工具,只是带历史的对话。 |
|
||||
| `reasonix run "task"` | 一次性,把答案直接流到 stdout。适合 shell 管道。 |
|
||||
| `reasonix doctor` | 环境体检(Node 版本、API Key、MCP 接线)。 |
|
||||
| `reasonix update` | 升级 Reasonix 本身。 |
|
||||
|
||||
其他子命令(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)见 `reasonix --help` 和 [CLI 参考](https://esengine.github.io/DeepSeek-Reasonix/#cli)。
|
||||
|
||||
**在其他目录工作:** Reasonix 把文件系统工具作用域绑定在启动目录。要在别的目录工作,传 `--dir`:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Dashboard ↔ TUI parity
|
||||
|
||||
Closing audit of #369. Inventories every slash command and how the
|
||||
web dashboard handles it.
|
||||
|
||||
## Coverage legend
|
||||
|
||||
- **panel** — has a dedicated SPA panel or modal beyond just typing the
|
||||
slash in the chat box.
|
||||
- **chat-box** — works by typing the slash into the web chat input.
|
||||
Result text shows in the dashboard scrollback as an info row. No
|
||||
dedicated UI; the chat box _is_ the UI.
|
||||
- **tui-only** — keyboard binding or process-controlling action that
|
||||
has no useful web equivalent. Stays a TUI affordance by design.
|
||||
|
||||
## Counts
|
||||
|
||||
Roughly **20 commands have a dedicated panel**, **38 work via the chat
|
||||
box**, and **`/exit` plus 4 raw keyboard shortcuts** stay TUI-only.
|
||||
Aliases (e.g. `/sessions` and `/resume` share one picker) collapse
|
||||
into one row.
|
||||
|
||||
## Core / observability
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/help` | command reference | chat-box |
|
||||
| `/keys` | keyboard shortcuts + prompt prefixes | chat-box |
|
||||
| `/status` | model + flags + ctx + session | chat-box |
|
||||
| `/context` | context-window breakdown (stacked bar) | chat-box · `ctxBreakdown` payload |
|
||||
| `/cost` | last turn / next turn estimate | chat-box · usage card |
|
||||
| `/stats` | cross-session cost dashboard | **panel** · Usage tab |
|
||||
| `/think` | last R1 reasoning dump | chat-box |
|
||||
| `/tool [N]` | dump full output of Nth tool call | chat-box |
|
||||
| `/clear` | wipe visible scrollback | chat-box |
|
||||
| `/new` (`/reset`) | wipe context + scrollback | chat-box |
|
||||
| `/exit` (`/quit`, `/q`) | quit the TUI | **tui-only** |
|
||||
| `/stop` | abort current model turn | chat-box |
|
||||
| `/retry` | resend last user message | chat-box |
|
||||
| `/compact` | fold older turns into summary | chat-box |
|
||||
| `/update` | show current vs latest version | chat-box |
|
||||
| `/doctor` | health check card | chat-box · doctor card |
|
||||
|
||||
## Model & compute
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/preset` | model bundle (auto / flash / pro) | **panel** · Settings → Defaults |
|
||||
| `/effort` | reasoning cap (high / max) | **panel** · Settings → Defaults |
|
||||
| `/model` | active model | **panel** · Settings → Runtime (D-4 #437) |
|
||||
| `/models` | list available models | chat-box |
|
||||
| `/pro` | arm v4-pro for next turn | **panel** · Settings → Compute (D-2 #435) |
|
||||
| `/budget` | session USD cap | **panel** · Settings → Budget + cockpit tile (D-3 #436) |
|
||||
| `/branch` | parallel sample count | chat-box · *deletion-bound* |
|
||||
| `/harvest` | Pillar-2 plan-state extraction | chat-box · *deletion-bound* |
|
||||
| `/loop` | auto-resubmit on interval | **panel** · Settings → Loop (D-5 #438) |
|
||||
|
||||
## Memory & project
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/memory [list / show / forget / clear]` | manage pinned memory | **panel** · Memory tab |
|
||||
| `/init` | synthesize baseline REASONIX.md | chat-box |
|
||||
| `/semantic` | semantic-search index status | chat-box |
|
||||
| `/search-engine` (`/se`) | switch web search backend | chat-box |
|
||||
| `/language` (`/lang`) | runtime language | **panel** · Settings → Language |
|
||||
|
||||
## Sessions
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/sessions` | list saved sessions | **panel** · SessionPicker modal (C-2 #423) |
|
||||
| `/resume` | open a session | **panel** · same picker |
|
||||
| `/rename` | rename current session | chat-box |
|
||||
| `/forget` | delete current session | chat-box |
|
||||
| `/plans` | active + archived plans | **panel** · Plans tab |
|
||||
| `/replay [N]` | read-only plan archive | **panel** · Viewer modal (C-5 #427) |
|
||||
|
||||
## MCP
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/mcp` (list) | bridged servers + tools | **panel** · MCP tab |
|
||||
| `/mcp browse` | marketplace + install | **panel** · MCP marketplace picker (C-4 #426) |
|
||||
| `/mcp disable` / `enable` / `reconnect` / `text` | server admin | chat-box |
|
||||
| `/resource [uri]` | browse / read MCP resources | chat-box |
|
||||
| `/prompt [name]` | browse / fetch MCP prompts | chat-box |
|
||||
|
||||
## Permissions & admin
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/permissions [list / add / remove / clear]` | shell allowlist | **panel** · Permissions tab |
|
||||
| `/hooks [reload]` | active hooks | **panel** · Hooks tab |
|
||||
| `/dashboard [stop]` | embedded dashboard lifecycle | chat-box · *intentional — admin command for the surface you're typing in* |
|
||||
|
||||
## Code-mode only
|
||||
|
||||
| Slash | Purpose | Coverage |
|
||||
|---|---|---|
|
||||
| `/init [force]` | scan + synthesize REASONIX.md | chat-box |
|
||||
| `/apply [N]` | commit pending edits | chat-box |
|
||||
| `/discard [N]` | drop pending edits | chat-box |
|
||||
| `/walk` | step through pending edits | **panel** · edit-review modal already covered web pre-#369 |
|
||||
| `/undo` | roll back last edit batch | chat-box |
|
||||
| `/history` | edit batch list | chat-box |
|
||||
| `/show [id]` | dump stored edit diff | chat-box |
|
||||
| `/commit "msg"` | git commit | chat-box |
|
||||
| `/checkpoint [name / list / forget]` | snapshot touched files | chat-box |
|
||||
| `/restore` | roll back to checkpoint | **panel** · CheckpointPicker modal (C-3 #425) |
|
||||
| `/plan [on / off]` | read-only plan mode | chat-box |
|
||||
| `/apply-plan` | force-approve pending plan | chat-box |
|
||||
| `/mode [review / auto / yolo]` | edit gate | **panel** · Chat header pill |
|
||||
| `/jobs` | list background jobs | chat-box |
|
||||
| `/kill <id>` | stop background job | chat-box |
|
||||
| `/logs <id> [lines]` | tail job output | chat-box |
|
||||
| `/skill [list / show / new / <name>]` | skill management | **panel** · Skills tab |
|
||||
|
||||
## Keyboard / TTY-native
|
||||
|
||||
These don't have slashes. They ride alongside the slash surface and
|
||||
stay TUI-only:
|
||||
|
||||
- `Esc` — abort current model turn (web equivalent: Abort button in chat)
|
||||
- `Shift+Tab` — cycle edit mode (web equivalent: mode pill in chat header)
|
||||
- `Ctrl-L`, `Ctrl-O`, `space`, `u` — TTY scroll / undo banner / pause hotkeys
|
||||
|
||||
## Done
|
||||
|
||||
Buckets covered:
|
||||
- **C** (#416, closed): pickers — `/sessions`, `/restore`, `/mcp browse`, `/replay` + `/walk` (already)
|
||||
- **D** (#428, closed): settings — `/preset`, `/effort`, `/model`, `/pro`, `/budget`, `/loop`, `/language`
|
||||
|
||||
Bucket A (text outputs that stay chat-box) and bucket B (structured
|
||||
outputs that already have panels) need no further work — the panels
|
||||
shipped in C/D plus the long-standing Memory / Permissions / Hooks /
|
||||
Skills / Plans / Usage tabs already cover every command whose output
|
||||
warranted a dedicated UI.
|
||||
|
||||
This file is the source of truth for the audit. Add a row when a
|
||||
new slash lands.
|
||||
@@ -2019,6 +2019,155 @@ input:focus, textarea:focus, select:focus { border-color: var(--c-brand); }
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.modal-picker-search {
|
||||
width: 100%;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
color: var(--fg-1);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-picker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
background: var(--bg-elev-2);
|
||||
padding: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-picker-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4px 8px;
|
||||
align-items: baseline;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm, 4px);
|
||||
color: var(--fg-1);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-picker-row:hover {
|
||||
background: var(--bg-elev-3, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
.modal-picker-row.selected {
|
||||
border-color: var(--accent, #fcd34d);
|
||||
background: var(--bg-elev-3, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
.modal-picker-title {
|
||||
grid-column: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-picker-badge {
|
||||
grid-column: 2;
|
||||
font-size: 11px;
|
||||
color: var(--fg-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elev-1, var(--bg-elev-2));
|
||||
}
|
||||
|
||||
.modal-picker-subtitle {
|
||||
grid-column: 1 / 3;
|
||||
font-size: 12px;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.modal-picker-meta {
|
||||
grid-column: 1 / 3;
|
||||
font-size: 11px;
|
||||
color: var(--fg-3, var(--fg-2));
|
||||
}
|
||||
|
||||
.modal-picker-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--fg-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-picker-more {
|
||||
align-self: center;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.modal-picker-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-picker-form input {
|
||||
width: 100%;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
color: var(--fg-1);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-viewer-steps {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modal-viewer-step {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--r-sm, 4px);
|
||||
background: var(--bg-elev-2);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.modal-viewer-step-mark {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.modal-viewer-step-done .modal-viewer-step-mark {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.modal-viewer-step-title {
|
||||
color: var(--fg-1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-viewer-step-done .modal-viewer-step-title {
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.modal-viewer-body {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--fg-1);
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Side-by-side diff for the edit-review modal — left is "before" (red
|
||||
* tint), right is "after" (green tint), context rows render unchanged.
|
||||
* Lines hljs-highlight per the file's language. */
|
||||
|
||||
@@ -104,6 +104,49 @@ interface RevisionSpec {
|
||||
remainingSteps: RevisionStep[];
|
||||
}
|
||||
|
||||
export type PickerActionName =
|
||||
| "pick"
|
||||
| "delete"
|
||||
| "rename"
|
||||
| "new"
|
||||
| "install"
|
||||
| "uninstall"
|
||||
| "load-more"
|
||||
| "refine"
|
||||
| "cancel";
|
||||
|
||||
export interface PickerItemSpec {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: string;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface PickerModalSpec {
|
||||
pickerKind: string;
|
||||
title: string;
|
||||
query?: string;
|
||||
items: PickerItemSpec[];
|
||||
actions: PickerActionName[];
|
||||
hasMore?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ViewerStep {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "done" | "queued";
|
||||
}
|
||||
|
||||
export interface ViewerModalSpec {
|
||||
viewerKind: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
steps?: ViewerStep[];
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
interface DiffEntry {
|
||||
kind: "context" | "ins" | "del";
|
||||
text: string;
|
||||
@@ -617,6 +660,220 @@ export function CheckpointModal({ modal, onResolve }: { modal: CheckpointSpec; o
|
||||
`;
|
||||
}
|
||||
|
||||
export function PickerModal({
|
||||
modal,
|
||||
onResolve,
|
||||
}: {
|
||||
modal: PickerModalSpec;
|
||||
onResolve: OnResolve;
|
||||
}) {
|
||||
useLang();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(modal.items[0]?.id ?? null);
|
||||
const [query, setQuery] = useState(modal.query ?? "");
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [renameText, setRenameText] = useState("");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newText, setNewText] = useState("");
|
||||
|
||||
const has = (a: PickerActionName) => modal.actions.includes(a);
|
||||
const selected = modal.items.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
const submitRefine = (next: string) => {
|
||||
setQuery(next);
|
||||
if (has("refine")) onResolve("picker", { action: "refine", query: next });
|
||||
};
|
||||
|
||||
const startRename = (id: string) => {
|
||||
const item = modal.items.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
setRenameTarget(id);
|
||||
setRenameText(item.title);
|
||||
};
|
||||
|
||||
const sendRename = () => {
|
||||
if (!renameTarget || !renameText.trim()) return;
|
||||
onResolve("picker", { action: "rename", id: renameTarget, text: renameText });
|
||||
setRenameTarget(null);
|
||||
setRenameText("");
|
||||
};
|
||||
|
||||
const sendNew = () => {
|
||||
onResolve("picker", newText.trim() ? { action: "new", text: newText } : { action: "new" });
|
||||
setShowNew(false);
|
||||
setNewText("");
|
||||
};
|
||||
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#fcd34d"
|
||||
icon="≡"
|
||||
title=${modal.title}
|
||||
subtitle=${modal.hint}
|
||||
>
|
||||
${
|
||||
has("refine")
|
||||
? html`<input
|
||||
class="modal-picker-search"
|
||||
type="search"
|
||||
placeholder=${t("modal.pickerFilter")}
|
||||
value=${query}
|
||||
onInput=${(e: Event) => submitRefine((e.target as HTMLInputElement).value)}
|
||||
/>`
|
||||
: null
|
||||
}
|
||||
<div class="modal-picker-list">
|
||||
${
|
||||
modal.items.length === 0
|
||||
? html`<div class="modal-picker-empty">${t("modal.pickerEmpty")}</div>`
|
||||
: modal.items.map(
|
||||
(it) => html`
|
||||
<button
|
||||
key=${it.id}
|
||||
class=${`modal-picker-row${it.id === selectedId ? " selected" : ""}`}
|
||||
onClick=${() => setSelectedId(it.id)}
|
||||
onDblClick=${() => has("pick") && onResolve("picker", { action: "pick", id: it.id })}
|
||||
>
|
||||
<span class="modal-picker-title">${it.title}</span>
|
||||
${it.badge ? html`<span class="modal-picker-badge">${it.badge}</span>` : null}
|
||||
${it.subtitle ? html`<span class="modal-picker-subtitle">${it.subtitle}</span>` : null}
|
||||
${it.meta ? html`<span class="modal-picker-meta">${it.meta}</span>` : null}
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
${
|
||||
modal.hasMore && has("load-more")
|
||||
? html`<button
|
||||
class="modal-picker-more"
|
||||
onClick=${() => onResolve("picker", { action: "load-more" })}
|
||||
>${t("modal.pickerLoadMore")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
renameTarget
|
||||
? html`
|
||||
<div class="modal-picker-form">
|
||||
<input
|
||||
type="text"
|
||||
value=${renameText}
|
||||
onInput=${(e: Event) => setRenameText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${sendRename} disabled=${!renameText.trim()}>${t("common.save")}</button>
|
||||
<button onClick=${() => setRenameTarget(null)}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: showNew
|
||||
? html`
|
||||
<div class="modal-picker-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder=${t("modal.pickerNewPlaceholder")}
|
||||
value=${newText}
|
||||
onInput=${(e: Event) => setNewText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${sendNew}>${t("common.add")}</button>
|
||||
<button onClick=${() => setShowNew(false)}>${t("common.back")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="modal-actions">
|
||||
${
|
||||
has("pick") && selected
|
||||
? html`<button
|
||||
class="primary"
|
||||
onClick=${() => onResolve("picker", { action: "pick", id: selected.id })}
|
||||
>${t("modal.pickerPick")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("install") && selected
|
||||
? html`<button
|
||||
class="primary"
|
||||
onClick=${() => onResolve("picker", { action: "install", id: selected.id })}
|
||||
>${t("modal.pickerInstall")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("uninstall") && selected
|
||||
? html`<button
|
||||
onClick=${() => onResolve("picker", { action: "uninstall", id: selected.id })}
|
||||
>${t("modal.pickerUninstall")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("rename") && selected
|
||||
? html`<button onClick=${() => startRename(selected.id)}>${t("modal.pickerRename")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("delete") && selected
|
||||
? html`<button
|
||||
class="danger"
|
||||
onClick=${() => onResolve("picker", { action: "delete", id: selected.id })}
|
||||
>${t("common.delete")}</button>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
has("new")
|
||||
? html`<button onClick=${() => setShowNew(true)}>${t("modal.pickerNew")}</button>`
|
||||
: null
|
||||
}
|
||||
<button onClick=${() => onResolve("picker", { action: "cancel" })}>${t("modal.cancel")}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function ViewerModal({
|
||||
modal,
|
||||
onResolve,
|
||||
}: {
|
||||
modal: ViewerModalSpec;
|
||||
onResolve: OnResolve;
|
||||
}) {
|
||||
useLang();
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#67e8f9"
|
||||
icon="◇"
|
||||
title=${modal.title}
|
||||
subtitle=${modal.meta}
|
||||
>
|
||||
${
|
||||
modal.steps && modal.steps.length > 0
|
||||
? html`
|
||||
<ol class="modal-viewer-steps">
|
||||
${modal.steps.map(
|
||||
(s: ViewerStep) => html`
|
||||
<li key=${s.id} class=${`modal-viewer-step modal-viewer-step-${s.status}`}>
|
||||
<span class="modal-viewer-step-mark">${s.status === "done" ? "✓" : "·"}</span>
|
||||
<span class="modal-viewer-step-title">${s.title}</span>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</ol>
|
||||
`
|
||||
: null
|
||||
}
|
||||
${
|
||||
modal.body
|
||||
? html`<div class="md modal-viewer-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body) }}></div>`
|
||||
: null
|
||||
}
|
||||
<div class="modal-actions">
|
||||
<button onClick=${() => onResolve("viewer", { action: "close" })}>${t("modal.viewerClose")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function RevisionModal({ modal, onResolve }: { modal: RevisionSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const riskColor = (r: string | undefined) =>
|
||||
|
||||
@@ -61,8 +61,36 @@ export const en = {
|
||||
effortHigh: "high (cheaper / faster)",
|
||||
webSearch: "web search",
|
||||
webSearchNote: "web_fetch + web_search tools",
|
||||
sectionCompute: "Compute",
|
||||
proNext: "/pro one-shot",
|
||||
proArm: "Arm for next turn",
|
||||
proArmed: "Armed — disarms after next turn",
|
||||
proNextNote: "next turn runs on deepseek-v4-pro, then auto-disarms",
|
||||
sectionBudget: "Budget",
|
||||
budgetOf: "of",
|
||||
budgetSetCap: "set a cap",
|
||||
budgetCustom: "custom",
|
||||
budgetBumpHint: "bump the cap to keep going",
|
||||
budgetClear: "Clear cap",
|
||||
budgetIdleLine: "warns at 80% · refuses past 100%",
|
||||
budgetWarnLine: "approaching cap — loop will refuse past 100%",
|
||||
budgetRefusing: "cap exhausted — next turn refused until bumped or cleared",
|
||||
sectionLoop: "Loop",
|
||||
loopIdleHint: "Auto-resubmit a prompt on a fixed interval.",
|
||||
loopCostHint: "Each iteration costs ~{cost} (last turn).",
|
||||
loopInterval: "interval",
|
||||
loopCustom: "custom",
|
||||
loopRangeError: "interval must fall in 5s..6h",
|
||||
loopPrompt: "prompt",
|
||||
loopPromptPlaceholder: "e.g. check the deploy status and report any errors",
|
||||
loopStart: "Start loop",
|
||||
loopStop: "Stop",
|
||||
loopRunning: "running",
|
||||
loopIter: "iter {iter}",
|
||||
loopFiresIn: "fires in {remaining}",
|
||||
sectionRuntime: "Runtime",
|
||||
activeModel: "active model",
|
||||
modelPricingLine: "${hit} hit · ${miss} miss · ${out} out per 1M tok",
|
||||
editMode: "edit mode",
|
||||
editModeNote: "switch from the Chat tab header",
|
||||
sectionLanguage: "Language",
|
||||
@@ -145,6 +173,7 @@ export const en = {
|
||||
tokens7d: "tokens · 7d",
|
||||
cacheHit: "cache hit",
|
||||
toolCalls24h: "tool calls · 24h",
|
||||
budget: "budget",
|
||||
currentSession: "current session",
|
||||
noSession: "No live session — /dashboard from inside reasonix code to attach.",
|
||||
promptTok: "prompt tok",
|
||||
@@ -375,6 +404,7 @@ export const en = {
|
||||
filterPlaceholder: "filter plans",
|
||||
active: "active",
|
||||
done: "done",
|
||||
idle: "idle",
|
||||
steps: "steps",
|
||||
pickHint: "Pick a plan on the left.",
|
||||
noTitle: "(no title)",
|
||||
@@ -507,5 +537,15 @@ export const en = {
|
||||
reject: "Reject",
|
||||
arguments: "arguments",
|
||||
revisePlaceholder: "What needs to change before the next step? Leave blank to just continue.",
|
||||
pickerFilter: "Filter…",
|
||||
pickerEmpty: "Nothing to show.",
|
||||
pickerLoadMore: "Load more",
|
||||
pickerPick: "Open",
|
||||
pickerInstall: "Install",
|
||||
pickerUninstall: "Uninstall",
|
||||
pickerRename: "Rename…",
|
||||
pickerNew: "New…",
|
||||
pickerNewPlaceholder: "Name (leave blank for default)",
|
||||
viewerClose: "Close",
|
||||
},
|
||||
};
|
||||
@@ -61,8 +61,36 @@ export const zhCN = {
|
||||
effortHigh: "high(更便宜 / 更快)",
|
||||
webSearch: "网页搜索",
|
||||
webSearchNote: "web_fetch + web_search 工具",
|
||||
sectionCompute: "计算",
|
||||
proNext: "/pro 单轮",
|
||||
proArm: "为下一轮装备",
|
||||
proArmed: "已装备 — 下一轮后自动解除",
|
||||
proNextNote: "下一轮使用 deepseek-v4-pro,之后自动解除",
|
||||
sectionBudget: "预算",
|
||||
budgetOf: "/",
|
||||
budgetSetCap: "设置上限",
|
||||
budgetCustom: "自定义",
|
||||
budgetBumpHint: "提高上限以继续",
|
||||
budgetClear: "清除上限",
|
||||
budgetIdleLine: "80% 时提醒 · 100% 后拒绝执行",
|
||||
budgetWarnLine: "接近上限 — 超过 100% 将拒绝执行",
|
||||
budgetRefusing: "已超出上限 — 提高或清除后才会继续",
|
||||
sectionLoop: "循环",
|
||||
loopIdleHint: "按固定间隔自动重新提交一段提示词。",
|
||||
loopCostHint: "每次迭代约 {cost}(上一轮成本)。",
|
||||
loopInterval: "间隔",
|
||||
loopCustom: "自定义",
|
||||
loopRangeError: "间隔需在 5s..6h 之间",
|
||||
loopPrompt: "提示词",
|
||||
loopPromptPlaceholder: "例如:检查部署状态并汇报任何错误",
|
||||
loopStart: "启动循环",
|
||||
loopStop: "停止",
|
||||
loopRunning: "运行中",
|
||||
loopIter: "第 {iter} 次",
|
||||
loopFiresIn: "{remaining} 后触发",
|
||||
sectionRuntime: "运行时",
|
||||
activeModel: "当前模型",
|
||||
modelPricingLine: "${hit} 命中 · ${miss} 未命中 · ${out} 输出 / 100 万 tok",
|
||||
editMode: "编辑模式",
|
||||
editModeNote: "在对话标签页头部切换",
|
||||
sectionLanguage: "语言",
|
||||
@@ -145,6 +173,7 @@ export const zhCN = {
|
||||
tokens7d: "tokens · 7 天",
|
||||
cacheHit: "缓存命中",
|
||||
toolCalls24h: "工具调用 · 24 小时",
|
||||
budget: "预算",
|
||||
currentSession: "当前会话",
|
||||
noSession: "无活跃会话 — 在 reasonix code 内执行 /dashboard 进行连接。",
|
||||
promptTok: "提示 tokens",
|
||||
@@ -375,6 +404,7 @@ export const zhCN = {
|
||||
filterPlaceholder: "筛选计划",
|
||||
active: "进行中",
|
||||
done: "已完成",
|
||||
idle: "未开始",
|
||||
steps: "步骤",
|
||||
pickHint: "选择左侧的计划。",
|
||||
noTitle: "(无标题)",
|
||||
@@ -507,5 +537,15 @@ export const zhCN = {
|
||||
reject: "拒绝",
|
||||
arguments: "参数",
|
||||
revisePlaceholder: "下一步之前需要更改什么?留空则直接继续。",
|
||||
pickerFilter: "过滤…",
|
||||
pickerEmpty: "暂无内容。",
|
||||
pickerLoadMore: "加载更多",
|
||||
pickerPick: "打开",
|
||||
pickerInstall: "安装",
|
||||
pickerUninstall: "卸载",
|
||||
pickerRename: "重命名…",
|
||||
pickerNew: "新建…",
|
||||
pickerNewPlaceholder: "名称(留空使用默认)",
|
||||
viewerClose: "关闭",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
export type BudgetState =
|
||||
| { kind: "off"; spent: number }
|
||||
| { kind: "running"; cap: number; spent: number; pct: number }
|
||||
| { kind: "warn"; cap: number; spent: number; pct: number }
|
||||
| { kind: "exhausted"; cap: number; spent: number; pct: number };
|
||||
|
||||
export function deriveBudgetState(
|
||||
cap: number | null | undefined,
|
||||
spent: number | null | undefined,
|
||||
): BudgetState {
|
||||
const safeSpent = typeof spent === "number" && spent >= 0 ? spent : 0;
|
||||
if (typeof cap !== "number" || cap <= 0) {
|
||||
return { kind: "off", spent: safeSpent };
|
||||
}
|
||||
const pct = (safeSpent / cap) * 100;
|
||||
if (pct >= 100) return { kind: "exhausted", cap, spent: safeSpent, pct };
|
||||
if (pct >= 80) return { kind: "warn", cap, spent: safeSpent, pct };
|
||||
return { kind: "running", cap, spent: safeSpent, pct };
|
||||
}
|
||||
|
||||
/** Default quick-cap menu — round dollar amounts users actually pick. */
|
||||
export const QUICK_CAPS_USD: ReadonlyArray<number> = [1, 5, 10, 25, 50];
|
||||
|
||||
/** 1.5× / 2× / 4× the current cap, snapped to a "nice" round number per bucket. */
|
||||
export function bumpSuggestions(currentCap: number): number[] {
|
||||
if (currentCap <= 0) return [];
|
||||
return [niceUp(currentCap * 1.5), niceUp(currentCap * 2), niceUp(currentCap * 4)];
|
||||
}
|
||||
|
||||
function niceUp(n: number): number {
|
||||
// Subtract a tiny epsilon before ceil so FP noise (0.4 * 1.5 = 0.6000…01)
|
||||
// doesn't bump a value to the next bucket.
|
||||
const eps = 1e-9;
|
||||
if (n < 1) return Math.ceil((n - eps) * 10) / 10;
|
||||
if (n < 10) return Math.ceil((n - eps) * 2) / 2;
|
||||
if (n < 100) return Math.ceil(n - eps);
|
||||
return Math.ceil((n - eps) / 5) * 5;
|
||||
}
|
||||
|
||||
/** Tone class shared between the cockpit tile and the settings gauge. */
|
||||
export function budgetTone(state: BudgetState): "" | "warn" | "err" {
|
||||
if (state.kind === "exhausted") return "err";
|
||||
if (state.kind === "warn") return "warn";
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type IntervalUnit = "s" | "m" | "h";
|
||||
|
||||
export interface LoopRunStatus {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
/** Wall-clock ms until the next fire — server reports a remaining duration, not an absolute. */
|
||||
nextFireMs: number;
|
||||
}
|
||||
|
||||
/** Quick-pick intervals in ms — covers the 95% of cases users actually run. */
|
||||
export const INTERVAL_PRESETS_MS: ReadonlyArray<{ ms: number; label: string }> = [
|
||||
{ ms: 30_000, label: "30s" },
|
||||
{ ms: 60_000, label: "1m" },
|
||||
{ ms: 5 * 60_000, label: "5m" },
|
||||
{ ms: 15 * 60_000, label: "15m" },
|
||||
{ ms: 60 * 60_000, label: "1h" },
|
||||
{ ms: 6 * 60 * 60_000, label: "6h" },
|
||||
];
|
||||
|
||||
const UNIT_TO_MS: Record<IntervalUnit, number> = {
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 60 * 60_000,
|
||||
};
|
||||
|
||||
const MIN_INTERVAL_MS = 5_000;
|
||||
const MAX_INTERVAL_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** Convert a "30" + "s" pair to ms, returning null if out of [5s, 6h]. */
|
||||
export function parseCustomInterval(value: string, unit: IntervalUnit): number | null {
|
||||
const n = Number.parseFloat(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const ms = Math.round(n * UNIT_TO_MS[unit]);
|
||||
if (ms < MIN_INTERVAL_MS || ms > MAX_INTERVAL_MS) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
/** Human-friendly "5m 12s" / "12s" / "2h 45m" — shows two largest non-zero units. */
|
||||
export function formatRemaining(ms: number): string {
|
||||
const safe = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(safe / 3600);
|
||||
const m = Math.floor((safe % 3600) / 60);
|
||||
const s = safe % 60;
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
ChoiceModal,
|
||||
EditReviewModal,
|
||||
type OnResolve,
|
||||
PickerModal,
|
||||
PlanModal,
|
||||
RevisionModal,
|
||||
ShellModal,
|
||||
ViewerModal,
|
||||
WorkspaceModal,
|
||||
parseToolArgs,
|
||||
} from "../components/chat-internals.js";
|
||||
@@ -684,7 +686,11 @@ export function ChatPanel() {
|
||||
? html`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "revision"
|
||||
? html`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: null
|
||||
: modal.kind === "picker"
|
||||
? html`<${PickerModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "viewer"
|
||||
? html`<${ViewerModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: null
|
||||
: null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -65,6 +66,9 @@ interface OverviewData {
|
||||
toolCount?: number;
|
||||
cwd?: string;
|
||||
cockpit?: CockpitData;
|
||||
budgetUsd?: number | null;
|
||||
/** Cumulative session spend in USD — set when a session is attached. */
|
||||
sessionSpendUsd?: number | null;
|
||||
}
|
||||
|
||||
function kpi(label: string, value: unknown, delta?: unknown, deltaTone?: "up" | "down" | "flat") {
|
||||
@@ -107,6 +111,25 @@ function balanceKpi(c: CockpitData) {
|
||||
return kpi(t("overview.balance"), `${symbol}${c.balance.total}`, c.balance.currency, "flat");
|
||||
}
|
||||
|
||||
function budgetKpi(o: OverviewData) {
|
||||
const state = deriveBudgetState(o.budgetUsd, o.cockpit?.currentSession?.totalCostUsd ?? null);
|
||||
if (state.kind === "off") return null;
|
||||
const tone = budgetTone(state);
|
||||
const valueColor =
|
||||
tone === "err"
|
||||
? "color:var(--c-err)"
|
||||
: tone === "warn"
|
||||
? "color:var(--c-warn)"
|
||||
: "";
|
||||
return html`
|
||||
<div class="kpi cock-w-1">
|
||||
<div class="label">${t("overview.budget")}</div>
|
||||
<div class="value" style=${valueColor}>${fmtUsd(state.spent)} / ${fmtUsd(state.cap)}</div>
|
||||
<div class=${`progress ${tone}`} style="margin-top:4px"><div class="progress-fill" style=${`width:${Math.min(100, state.pct)}%`}></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function tokens7dKpi(c: CockpitData) {
|
||||
if (!c.tokens7d) return kpi(t("overview.tokens7d"), "—", t("overview.noUsageYet"), "flat");
|
||||
const d = deltaPctText(c.tokens7d.deltaPct);
|
||||
@@ -285,6 +308,7 @@ export function OverviewPanel() {
|
||||
${tokens7dKpi(c)}
|
||||
${cacheHitKpi(c)}
|
||||
${toolCallsKpi(c)}
|
||||
${budgetKpi(o)}
|
||||
|
||||
${currentSessionBlock(c)}
|
||||
${costTrendSpark(c)}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+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>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.30.5",
|
||||
"version": "0.31.0",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -452,7 +452,9 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
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
|
||||
|
||||
+213
-53
@@ -7,7 +7,14 @@ import {
|
||||
openEventSink,
|
||||
} from "../../adapters/event-sink-jsonl.js";
|
||||
import { type AtUrlExpansion, expandAtMentions, expandAtUrls } from "../../at-mentions.js";
|
||||
import { createCheckpoint } from "../../code/checkpoints.js";
|
||||
import {
|
||||
type CheckpointMeta,
|
||||
createCheckpoint,
|
||||
deleteCheckpoint,
|
||||
fmtAgo,
|
||||
listCheckpoints,
|
||||
restoreCheckpoint,
|
||||
} from "../../code/checkpoints.js";
|
||||
import {
|
||||
type EditBlock,
|
||||
applyEditBlocks,
|
||||
@@ -34,7 +41,7 @@ import {
|
||||
import { Eventizer } from "../../core/eventize.js";
|
||||
import { pauseGate } from "../../core/pause-gate.js";
|
||||
import { type ResolvedHook, formatHookOutcomeMessage, loadHooks, runHooks } from "../../hooks.js";
|
||||
import { onLanguageChange } from "../../i18n/index.js";
|
||||
import { onLanguageChange, t } from "../../i18n/index.js";
|
||||
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
|
||||
import type { LoopEvent } from "../../loop.js";
|
||||
import {
|
||||
@@ -51,6 +58,7 @@ import type {
|
||||
ActiveModal,
|
||||
DashboardEvent,
|
||||
DashboardMessage,
|
||||
PickerResolution,
|
||||
SubmitResult,
|
||||
} from "../../server/context.js";
|
||||
import { type DashboardServerHandle, startDashboardServer } from "../../server/index.js";
|
||||
@@ -69,6 +77,7 @@ import { formatSubagentResult, spawnSubagent } from "../../tools/subagent.js";
|
||||
import { webFetch } from "../../tools/web.js";
|
||||
import { openTranscriptFile } from "../../transcript/log.js";
|
||||
import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
|
||||
import { CheckpointPicker } from "./CheckpointPicker.js";
|
||||
import { ChoiceConfirm, type ChoiceConfirmChoice } from "./ChoiceConfirm.js";
|
||||
import { EditConfirm, type EditReviewChoice } from "./EditConfirm.js";
|
||||
import { McpHub } from "./McpHub.js";
|
||||
@@ -85,6 +94,8 @@ import { SlashArgPicker } from "./SlashArgPicker.js";
|
||||
import { SlashSuggestions } from "./SlashSuggestions.js";
|
||||
import { WelcomeBanner } from "./WelcomeBanner.js";
|
||||
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
|
||||
import type { PickerSnapshot, ViewerSnapshot } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useViewerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { formatEditResults } from "./edit-history.js";
|
||||
import { loopEventToDashboard } from "./effects/loop-to-dashboard.js";
|
||||
import { appendGlobalMemory, appendProjectMemory, detectHashMemory } from "./hash-memory.js";
|
||||
@@ -524,6 +535,9 @@ function AppInner({
|
||||
/** True while the SessionPicker is open mid-chat (triggered by `/sessions`). */
|
||||
const [pendingSessionsPicker, setPendingSessionsPicker] = useState(false);
|
||||
const [sessionsPickerList, setSessionsPickerList] = useState<ReturnType<typeof listSessions>>([]);
|
||||
/** True while the CheckpointPicker is open mid-chat (triggered by bare `/restore`). */
|
||||
const [pendingCheckpointPicker, setPendingCheckpointPicker] = useState(false);
|
||||
const [checkpointPickerList, setCheckpointPickerList] = useState<CheckpointMeta[]>([]);
|
||||
/** Opens the unified McpHub modal — null when closed. `tab` selects the initial tab. */
|
||||
const [pendingMcpHub, setPendingMcpHub] = useState<{ tab: "live" | "marketplace" } | null>(null);
|
||||
/** True while the ModelPicker is open mid-chat (triggered by bare `/model`). */
|
||||
@@ -590,6 +604,7 @@ function AppInner({
|
||||
!!pendingPlan ||
|
||||
!!pendingReviseEditor ||
|
||||
!!pendingSessionsPicker ||
|
||||
!!pendingCheckpointPicker ||
|
||||
!!pendingMcpHub ||
|
||||
pendingModelPicker ||
|
||||
!!stagedInput ||
|
||||
@@ -664,6 +679,13 @@ function AppInner({
|
||||
// sync with the TUI. The Set is keyed by the subscriber function
|
||||
// itself; subscribeEvents returns an unsubscribe closure.
|
||||
const eventSubscribersRef = useRef<Set<(ev: DashboardEvent) => void>>(new Set());
|
||||
/** Only one picker mounts at a time; snapshot feeds `getActiveModal` for late SSE clients. */
|
||||
const activePickerResolverRef = useRef<((res: PickerResolution) => void) | null>(null);
|
||||
const activePickerSnapshotRef = useRef<PickerSnapshot | null>(null);
|
||||
/** Active read-only viewer (e.g. /replay plan archive). Same late-SSE concern, simpler resolver (close only). */
|
||||
const activeViewerResolverRef = useRef<(() => void) | null>(null);
|
||||
const activeViewerSnapshotRef = useRef<ViewerSnapshot | null>(null);
|
||||
const [pendingReplayViewer, setPendingReplayViewer] = useState<ViewerSnapshot | null>(null);
|
||||
// Structured steps captured from the most recent `submit_plan` call.
|
||||
// Populated only when the model supplied `steps`; used by the
|
||||
// `mark_step_complete` handler to look up the step title and compute
|
||||
@@ -888,6 +910,10 @@ function AppInner({
|
||||
// balance. useSessionInfo refreshes balance every few minutes; we
|
||||
// forward to the dashboard without re-minting startDashboard.
|
||||
const balanceRef = useRef<typeof balance>(null);
|
||||
const modelsRef = useRef<string[] | null>(null);
|
||||
useEffect(() => {
|
||||
modelsRef.current = models;
|
||||
}, [models]);
|
||||
useEffect(() => {
|
||||
balanceRef.current = balance;
|
||||
walletCurrencyRef.current = balance?.currency;
|
||||
@@ -913,6 +939,28 @@ function AppInner({
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
const pickerPorts = useMemo(
|
||||
() => ({
|
||||
broadcast: broadcastDashboardEvent,
|
||||
resolverRef: activePickerResolverRef,
|
||||
snapshotRef: activePickerSnapshotRef,
|
||||
}),
|
||||
[broadcastDashboardEvent],
|
||||
);
|
||||
const viewerPorts = useMemo(
|
||||
() => ({
|
||||
broadcast: broadcastDashboardEvent,
|
||||
resolverRef: activeViewerResolverRef,
|
||||
snapshotRef: activeViewerSnapshotRef,
|
||||
}),
|
||||
[broadcastDashboardEvent],
|
||||
);
|
||||
useViewerBroadcast(
|
||||
!!pendingReplayViewer,
|
||||
pendingReplayViewer ?? { viewerKind: "replay-plan", title: "" },
|
||||
() => setPendingReplayViewer(null),
|
||||
viewerPorts,
|
||||
);
|
||||
|
||||
// Broadcast busy-state changes so the web Chat tab can disable its
|
||||
// submit button while a turn is in flight. Mirrors what the TUI's
|
||||
@@ -1075,15 +1123,11 @@ function AppInner({
|
||||
if (sessionBannerShown.current) return;
|
||||
sessionBannerShown.current = true;
|
||||
if (!session) {
|
||||
log.pushInfo("▸ ephemeral chat (no session persistence) — drop --no-session to enable");
|
||||
log.pushInfo(t("ui.ephemeralSession"));
|
||||
} else if (loop.resumedMessageCount > 0) {
|
||||
log.pushInfo(
|
||||
`▸ resumed session "${session}" with ${loop.resumedMessageCount} prior messages · /forget to start over · /sessions to list`,
|
||||
);
|
||||
log.pushInfo(t("ui.resumedSession", { name: session, count: loop.resumedMessageCount }));
|
||||
} else {
|
||||
log.pushInfo(
|
||||
`▸ session "${session}" (new) — auto-saved as you chat · /forget to delete · /sessions to list`,
|
||||
);
|
||||
log.pushInfo(t("ui.newSession", { name: session }));
|
||||
}
|
||||
// Restore any pending edit queue from a prior run that was
|
||||
// interrupted before /apply or /discard. The checkpoint file sits
|
||||
@@ -1094,9 +1138,7 @@ function AppInner({
|
||||
if (restored && restored.length > 0) {
|
||||
pendingEdits.current = restored;
|
||||
syncPendingCount();
|
||||
log.pushInfo(
|
||||
`▸ restored ${restored.length} pending edit block(s) from an interrupted prior run — /apply to commit or /discard to drop.`,
|
||||
);
|
||||
log.pushInfo(t("ui.restoredEdits", { count: restored.length }));
|
||||
}
|
||||
}
|
||||
// Restore structured plan state from a prior run. plan.json sits
|
||||
@@ -1119,7 +1161,7 @@ function AppInner({
|
||||
const done = new Set(restoredPlan.completedStepIds);
|
||||
const summary = restoredPlan.summary ? ` — ${restoredPlan.summary}` : "";
|
||||
log.showPlan({
|
||||
title: `Resumed plan · ${when}${summary}`,
|
||||
title: t("ui.resumedPlan", { when, summary }),
|
||||
steps: restoredPlan.steps.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
@@ -1135,14 +1177,7 @@ function AppInner({
|
||||
// per install; the config flag suppresses re-display on every
|
||||
// relaunch. Skips chat mode — those shortcuts don't apply there.
|
||||
if (codeMode && !editModeHintShown()) {
|
||||
log.pushInfo(
|
||||
"▸ TIP: edit-gate keybindings\n" +
|
||||
" y / n accept or drop pending edits\n" +
|
||||
" Shift+Tab switch review ↔ AUTO (persisted; AUTO applies instantly)\n" +
|
||||
" u undo the last auto-applied batch (within the 5s banner)\n" +
|
||||
" Current mode is shown in the bottom status bar. Run /keys anytime for the full list.\n" +
|
||||
" (This tip shows once — suppressed after.)",
|
||||
);
|
||||
log.pushInfo(t("ui.tipEditBindings"));
|
||||
markEditModeHintShown();
|
||||
}
|
||||
}, [session, loop, codeMode, syncPendingCount, log]);
|
||||
@@ -1232,8 +1267,8 @@ function AppInner({
|
||||
const remaining = pendingEdits.current.length;
|
||||
log.pushInfo(
|
||||
remaining > 0
|
||||
? `▸ walk cancelled — ${remaining} block(s) still pending.`
|
||||
: "▸ walk cancelled.",
|
||||
? t("app.walkCancelledRemaining", { count: remaining })
|
||||
: t("app.walkCancelled"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1249,6 +1284,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -1264,10 +1300,10 @@ function AppInner({
|
||||
setEditMode(next);
|
||||
const message =
|
||||
next === "yolo"
|
||||
? "▸ edit mode: YOLO — edits AND shell commands auto-run. /undo still rolls back edits. Use carefully."
|
||||
? t("app.editModeYolo")
|
||||
: next === "auto"
|
||||
? "▸ edit mode: AUTO — edits apply immediately; press u within 5s to undo (space pauses the timer). Shell commands still ask."
|
||||
: "▸ edit mode: review — edits queue for /apply (or y) / /discard (or n)";
|
||||
? t("app.editModeAuto")
|
||||
: t("app.editModeReview");
|
||||
log.pushInfo(message);
|
||||
return;
|
||||
}
|
||||
@@ -1283,6 +1319,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -1310,6 +1347,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -1332,6 +1370,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -1512,17 +1551,17 @@ function AppInner({
|
||||
|
||||
if (choice === "reject") {
|
||||
const context = denyContext ? ` because: ${denyContext}` : "";
|
||||
log.pushInfo(`▸ rejected edit to ${block.path}${context}`);
|
||||
log.pushInfo(t("app.rejectedEdit", { path: block.path, context }));
|
||||
return `User rejected this edit to ${block.path}${context}. Don't retry the same SEARCH/REPLACE — either try a different approach or ask the user what they want instead.`;
|
||||
}
|
||||
if (choice === "apply-rest-of-turn") {
|
||||
turnEditPolicyRef.current = "apply-all";
|
||||
log.pushInfo("▸ auto-approving remaining edits for this turn");
|
||||
log.pushInfo(t("app.autoApprovingRest"));
|
||||
return applyNow();
|
||||
}
|
||||
if (choice === "flip-to-auto") {
|
||||
setEditMode("auto");
|
||||
log.pushInfo("▸ flipped to AUTO mode for the rest of the session (persisted)");
|
||||
log.pushInfo(t("app.flippedAutoSession"));
|
||||
return applyNow();
|
||||
}
|
||||
// "apply"
|
||||
@@ -1630,6 +1669,7 @@ function AppInner({
|
||||
autoEscalate: settings.autoEscalate,
|
||||
reasoningEffort: settings.reasoningEffort,
|
||||
});
|
||||
agentStore.dispatch({ type: "session.model.change", model: settings.model });
|
||||
const canonical: "auto" | "flash" | "pro" =
|
||||
settings.model === "deepseek-v4-pro" ? "pro" : settings.autoEscalate ? "auto" : "flash";
|
||||
setPreset(canonical);
|
||||
@@ -1637,6 +1677,21 @@ function AppInner({
|
||||
applyEffortLive: (effort) => {
|
||||
loop.configure({ reasoningEffort: effort });
|
||||
},
|
||||
applyModelLive: (model) => {
|
||||
loop.configure({ model });
|
||||
agentStore.dispatch({ type: "session.model.change", model });
|
||||
},
|
||||
getModels: () => modelsRef.current,
|
||||
setProNextLive: (armed) => {
|
||||
if (armed) loop.armProForNextTurn();
|
||||
else loop.disarmPro();
|
||||
},
|
||||
setBudgetUsdLive: (usd) => {
|
||||
loop.setBudget(usd);
|
||||
},
|
||||
getLoopRunStatus: () => getLoopStatus(),
|
||||
startAutoLoop: (intervalMs, prompt) => startLoop(intervalMs, prompt),
|
||||
stopAutoLoop: () => stopLoop(),
|
||||
// ---------- Chat bridge ----------
|
||||
getMessages: (): DashboardMessage[] =>
|
||||
cardsToDashboardMessages(agentStore.getState().cards),
|
||||
@@ -1741,6 +1796,14 @@ function AppInner({
|
||||
...(pendingRevision.summary ? { summary: pendingRevision.summary } : {}),
|
||||
};
|
||||
}
|
||||
const picker = activePickerSnapshotRef.current;
|
||||
if (picker) {
|
||||
return { kind: "picker", ...picker };
|
||||
}
|
||||
const viewer = activeViewerSnapshotRef.current;
|
||||
if (viewer) {
|
||||
return { kind: "viewer", ...viewer };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
resolveShellConfirm: (choice) => {
|
||||
@@ -1792,6 +1855,14 @@ function AppInner({
|
||||
resolveReviseConfirm: (choice) => {
|
||||
Promise.resolve(handleReviseConfirmRef.current(choice)).catch(() => undefined);
|
||||
},
|
||||
resolvePicker: (resolution) => {
|
||||
const fn = activePickerResolverRef.current;
|
||||
if (fn) Promise.resolve(fn(resolution)).catch(() => undefined);
|
||||
},
|
||||
resolveViewer: () => {
|
||||
const fn = activeViewerResolverRef.current;
|
||||
if (fn) Promise.resolve(fn()).catch(() => undefined);
|
||||
},
|
||||
// ---------- v0.14 mutation surface ----------
|
||||
reloadHooks: () => {
|
||||
const fresh = loadHooks({
|
||||
@@ -1833,6 +1904,9 @@ function AppInner({
|
||||
pendingRevision,
|
||||
agentStore,
|
||||
mcpRuntime,
|
||||
getLoopStatus,
|
||||
startLoop,
|
||||
stopLoop,
|
||||
]);
|
||||
|
||||
const stopDashboard = useCallback(async (): Promise<void> => {
|
||||
@@ -1845,7 +1919,7 @@ function AppInner({
|
||||
} catch {
|
||||
/* swallow — server going down is best-effort */
|
||||
}
|
||||
log.pushInfo("▸ dashboard stopped.");
|
||||
log.pushInfo(t("app.dashboardStopped"));
|
||||
}, [log]);
|
||||
|
||||
const getDashboardUrl = useCallback((): string | null => {
|
||||
@@ -1874,9 +1948,7 @@ function AppInner({
|
||||
// web UI is unreachable — port already in use, permission
|
||||
// denied, etc. Don't block the TUI; everything else keeps working.
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
log.pushInfo(
|
||||
`▲ dashboard auto-start failed (${reason}) — try /dashboard, or pass --no-dashboard to silence`,
|
||||
);
|
||||
log.pushInfo(t("ui.dashboardAutoStartFailed", { reason }));
|
||||
});
|
||||
}, [noDashboard, startDashboard, log]);
|
||||
|
||||
@@ -1918,7 +1990,7 @@ function AppInner({
|
||||
setEditMode("auto");
|
||||
saveEditMode("auto");
|
||||
log.pushInfo(codeApply([1]));
|
||||
log.pushInfo("▸ flipped to AUTO mode — future edits will apply immediately. Walk exited.");
|
||||
log.pushInfo(t("app.flippedAutoWalk"));
|
||||
setWalkthroughActive(false);
|
||||
return;
|
||||
}
|
||||
@@ -2016,11 +2088,11 @@ function AppInner({
|
||||
const result = isGlobal
|
||||
? appendGlobalMemory(hashParse.note)
|
||||
: appendProjectMemory(memRoot, hashParse.note);
|
||||
const verb = result.created ? "created" : "appended to";
|
||||
const scopeTag = isGlobal ? "global" : "project";
|
||||
log.pushInfo(`▸ noted (${scopeTag}) — ${verb} ${result.path}`);
|
||||
const verb = result.created ? t("app.notedVerbCreated") : t("app.notedVerbAppended");
|
||||
const scopeTag = isGlobal ? t("app.notedScopeGlobal") : t("app.notedScopeProject");
|
||||
log.pushInfo(t("app.notedMemory", { scope: scopeTag, verb, path: result.path }));
|
||||
} catch (err) {
|
||||
log.pushWarning("# memory write failed", (err as Error).message);
|
||||
log.pushWarning(t("app.memoryWriteFailed"), (err as Error).message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2056,7 +2128,7 @@ function AppInner({
|
||||
content: formatBangUserMessage(bangCmd, formatted),
|
||||
});
|
||||
} catch (err) {
|
||||
log.pushWarning("! command failed", (err as Error).message);
|
||||
log.pushWarning(t("app.commandFailed"), (err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -2151,6 +2223,17 @@ function AppInner({
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openCheckpointPicker) {
|
||||
if (!codeMode) {
|
||||
log.pushInfo(t("app.restoreCodeOnly"));
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
setCheckpointPickerList([...listCheckpoints(currentRootDir)].reverse());
|
||||
setPendingCheckpointPicker(true);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openMcpHub) {
|
||||
setPendingMcpHub({ tab: result.openMcpHub.tab });
|
||||
pushHistory(text);
|
||||
@@ -2166,6 +2249,22 @@ function AppInner({
|
||||
setInput(`/${result.openArgPickerFor} `);
|
||||
return;
|
||||
}
|
||||
if (result.replayPlan) {
|
||||
const rp = result.replayPlan;
|
||||
const titleSuffix = rp.summary ? ` — ${rp.summary}` : "";
|
||||
const done = new Set(rp.completedStepIds);
|
||||
setPendingReplayViewer({
|
||||
viewerKind: "replay-plan",
|
||||
title: `Replay #${rp.index}/${rp.total} · ${rp.relativeTime}${titleSuffix}`,
|
||||
body: rp.body,
|
||||
steps: rp.steps.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
status: done.has(s.id) ? "done" : "queued",
|
||||
})),
|
||||
meta: rp.archiveBasename,
|
||||
});
|
||||
}
|
||||
const outcome = applySlashResult(result, {
|
||||
log,
|
||||
stdoutWrite: (chunk) => stdout?.write(chunk),
|
||||
@@ -2199,7 +2298,7 @@ function AppInner({
|
||||
});
|
||||
for (const o of promptReport.outcomes) {
|
||||
if (o.decision === "pass") continue;
|
||||
log.pushWarning("UserPromptSubmit hook", formatHookOutcomeMessage(o));
|
||||
log.pushWarning(t("app.hookUserPromptSubmit"), formatHookOutcomeMessage(o));
|
||||
}
|
||||
if (promptReport.blocked) return;
|
||||
}
|
||||
@@ -2262,7 +2361,7 @@ function AppInner({
|
||||
|
||||
const flush = () => {
|
||||
if (!contentBuf.current && !reasoningBuf.current && !toolCallBuildBuf.current) return;
|
||||
translator.flushBuffers(reasoningBuf.current, contentBuf.current);
|
||||
translator.flushBuffers(reasoningBuf.current, contentBuf.current, loop.currentCallModel);
|
||||
streamRef.text += contentBuf.current;
|
||||
streamRef.reasoning += reasoningBuf.current;
|
||||
if (toolCallBuildBuf.current) {
|
||||
@@ -2294,7 +2393,7 @@ function AppInner({
|
||||
const parts: string[] = [];
|
||||
if (inlined.length > 0) parts.push(`inlined ${inlined.join(", ")}`);
|
||||
if (skipped.length > 0) parts.push(`skipped ${skipped.join(", ")}`);
|
||||
if (parts.length > 0) log.pushInfo(`▸ @mentions: ${parts.join("; ")}`);
|
||||
if (parts.length > 0) log.pushInfo(t("app.atMentions", { parts: parts.join("; ") }));
|
||||
}
|
||||
}
|
||||
// Expand `@http(s)://...` URL mentions. Available in any mode (chat
|
||||
@@ -2323,12 +2422,12 @@ function AppInner({
|
||||
const parts: string[] = [];
|
||||
if (inlined.length > 0) parts.push(`inlined ${inlined.join("; ")}`);
|
||||
if (skipped.length > 0) parts.push(`skipped ${skipped.join("; ")}`);
|
||||
if (parts.length > 0) log.pushInfo(`▸ @url: ${parts.join("; ")}`);
|
||||
if (parts.length > 0) log.pushInfo(t("app.atUrl", { parts: parts.join("; ") }));
|
||||
}
|
||||
} catch (err) {
|
||||
// expandAtUrls itself only throws on misconfiguration (no
|
||||
// fetcher). Per-URL failures are surfaced via the skip path.
|
||||
log.pushWarning("@url expansion failed", (err as Error).message);
|
||||
log.pushWarning(t("app.atUrlFailed"), (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2487,7 +2586,7 @@ function AppInner({
|
||||
});
|
||||
for (const o of stopReport.outcomes) {
|
||||
if (o.decision === "pass") continue;
|
||||
log.pushWarning("Stop hook", formatHookOutcomeMessage(o));
|
||||
log.pushWarning(t("app.hookStop"), formatHookOutcomeMessage(o));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -2599,15 +2698,17 @@ function AppInner({
|
||||
|
||||
if (choice === "deny") {
|
||||
const context = denyContext ? ` because: ${denyContext}` : "";
|
||||
log.pushInfo(`▸ denied: ${cmd}${context}`);
|
||||
log.pushInfo(t("app.denied", { cmd, context }));
|
||||
pauseGate.resolve(id, { type: "deny", denyContext });
|
||||
} else if (choice === "always_allow") {
|
||||
const prefix = derivePrefix(cmd);
|
||||
log.pushInfo(`▸ always allowed "${prefix}" for ${currentRootDir}`);
|
||||
log.pushInfo(t("app.alwaysAllowed", { prefix, dir: currentRootDir }));
|
||||
pauseGate.resolve(id, { type: "always_allow", prefix });
|
||||
} else {
|
||||
log.pushInfo(
|
||||
kind === "run_background" ? `▸ starting (background): ${cmd}` : `▸ running: ${cmd}`,
|
||||
kind === "run_background"
|
||||
? t("app.startingBackground", { cmd })
|
||||
: t("app.runningCommand", { cmd }),
|
||||
);
|
||||
pauseGate.resolve(id, { type: "run_once" });
|
||||
}
|
||||
@@ -2947,7 +3048,11 @@ function AppInner({
|
||||
source: "auto-pre-restore",
|
||||
});
|
||||
log.pushInfo(
|
||||
`⛁ checkpoint saved · ${meta.id} · ${meta.fileCount} file${meta.fileCount === 1 ? "" : "s"} · /restore ${meta.id} to roll back this step`,
|
||||
t("app.checkpointSaved", {
|
||||
id: meta.id,
|
||||
count: meta.fileCount,
|
||||
s: meta.fileCount === 1 ? "" : "s",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
@@ -2963,8 +3068,8 @@ function AppInner({
|
||||
const counter = snap.total > 0 ? ` (${snap.completed}/${snap.total})` : "";
|
||||
log.pushInfo(
|
||||
choice === "continue"
|
||||
? `▸ continuing after ${label}${counter}`
|
||||
: `▸ plan stopped at ${label}${counter}`,
|
||||
? t("app.continuingAfter", { label, counter })
|
||||
: t("app.planStoppedAt", { label, counter }),
|
||||
);
|
||||
},
|
||||
[pendingCheckpoint, codeMode, touchedPaths, log],
|
||||
@@ -2995,8 +3100,11 @@ function AppInner({
|
||||
);
|
||||
}
|
||||
const marker = trimmed
|
||||
? `▸ revising after ${label} — ${trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed}`
|
||||
: `▸ continuing after ${label}`;
|
||||
? t("app.revisingAfter", {
|
||||
label,
|
||||
feedback: trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed,
|
||||
})
|
||||
: t("app.continuingAfter", { label, counter: "" });
|
||||
log.pushInfo(marker);
|
||||
},
|
||||
[log],
|
||||
@@ -3120,6 +3228,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3131,6 +3240,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3142,6 +3252,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3155,6 +3266,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3178,6 +3290,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3192,6 +3305,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview ? (
|
||||
@@ -3244,11 +3358,56 @@ function AppInner({
|
||||
completedStepIds={completedStepIdsRef.current}
|
||||
onChoose={stableHandleCheckpointConfirm}
|
||||
/>
|
||||
) : pendingCheckpointPicker ? (
|
||||
<CheckpointPicker
|
||||
checkpoints={checkpointPickerList}
|
||||
workspace={currentRootDir}
|
||||
pickerPorts={pickerPorts}
|
||||
onChoose={(outcome) => {
|
||||
if (outcome.kind === "quit") {
|
||||
setPendingCheckpointPicker(false);
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === "restore") {
|
||||
const target = checkpointPickerList.find((c) => c.id === outcome.id);
|
||||
setPendingCheckpointPicker(false);
|
||||
if (!target) return;
|
||||
const result = restoreCheckpoint(currentRootDir, target.id);
|
||||
const lines = [
|
||||
`▸ restored "${target.name}" (${target.id.slice(0, 7)}, ${fmtAgo(target.createdAt)})`,
|
||||
];
|
||||
if (result.restored.length > 0) {
|
||||
lines.push(
|
||||
` wrote ${result.restored.length} file${result.restored.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
if (result.removed.length > 0) {
|
||||
lines.push(
|
||||
` removed ${result.removed.length} file${result.removed.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
if (result.skipped.length > 0) {
|
||||
lines.push(
|
||||
` skipped ${result.skipped.length} file${result.skipped.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
log.pushInfo(lines.join("\n"));
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === "delete") {
|
||||
const target = checkpointPickerList.find((c) => c.id === outcome.id);
|
||||
if (!target) return;
|
||||
deleteCheckpoint(currentRootDir, target.id);
|
||||
setCheckpointPickerList([...listCheckpoints(currentRootDir)].reverse());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : pendingSessionsPicker ? (
|
||||
<SessionPicker
|
||||
sessions={sessionsPickerList}
|
||||
workspace={currentRootDir}
|
||||
walletCurrency={walletCurrencyRef.current}
|
||||
pickerPorts={pickerPorts}
|
||||
onChoose={(outcome) => {
|
||||
if (outcome.kind === "open") {
|
||||
setPendingSessionsPicker(false);
|
||||
@@ -3306,6 +3465,7 @@ function AppInner({
|
||||
initialTab={pendingMcpHub.tab}
|
||||
liveServers={liveMcpServers}
|
||||
configPath={defaultConfigPath()}
|
||||
pickerPorts={pickerPorts}
|
||||
onClose={() => setPendingMcpHub(null)}
|
||||
postInfo={(text) => log.pushInfo(text)}
|
||||
applyAppend={(target, addedTools) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+10
-1
@@ -4,6 +4,7 @@ import { Box, Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { McpBrowser } from "./McpBrowser.js";
|
||||
import { McpMarketplace } from "./McpMarketplace.js";
|
||||
import type { PickerBroadcastPorts } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import type { ApplyAppend } from "./mcp-reconnect-kickoff.js";
|
||||
import type { McpServerSummary } from "./slash/types.js";
|
||||
@@ -23,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({
|
||||
@@ -33,6 +36,7 @@ export function McpHub({
|
||||
postInfo,
|
||||
applyAppend,
|
||||
reloadMcp,
|
||||
pickerPorts,
|
||||
}: McpHubProps) {
|
||||
const [tab, setTab] = useState<McpHubTab>(initialTab);
|
||||
|
||||
@@ -64,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
-27
@@ -11,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";
|
||||
|
||||
@@ -26,6 +27,7 @@ export interface McpMarketplaceProps {
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
}>;
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -38,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
|
||||
@@ -65,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,
|
||||
@@ -127,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…" }));
|
||||
@@ -175,7 +207,6 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = specStringFor(entry.name, install);
|
||||
const cfg = readConfig();
|
||||
@@ -215,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) => {
|
||||
|
||||
@@ -19,6 +19,13 @@ 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;
|
||||
@@ -74,15 +81,19 @@ export function PromptInput({
|
||||
const registerPaste = (content: string) => {
|
||||
const v = lastLocalValueRef.current;
|
||||
const c = cursorRef.current;
|
||||
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
|
||||
nextPasteIdRef.current = id + 1;
|
||||
pastesRef.current.set(id, makePasteEntry(id, content));
|
||||
const sentinel = encodePasteSentinel(id);
|
||||
const next = v.slice(0, c) + sentinel + v.slice(c);
|
||||
const insertion = shouldInlinePaste(content)
|
||||
? content
|
||||
: (() => {
|
||||
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
|
||||
nextPasteIdRef.current = id + 1;
|
||||
pastesRef.current.set(id, makePasteEntry(id, content));
|
||||
return encodePasteSentinel(id);
|
||||
})();
|
||||
const next = v.slice(0, c) + insertion + v.slice(c);
|
||||
lastLocalValueRef.current = next;
|
||||
cursorRef.current = c + 1;
|
||||
cursorRef.current = c + insertion.length;
|
||||
onChange(next);
|
||||
setCursor(c + 1);
|
||||
setCursor(c + insertion.length);
|
||||
};
|
||||
|
||||
useKeystroke((ev) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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, formatCost } from "./theme/tokens.js";
|
||||
|
||||
@@ -18,6 +19,8 @@ export interface SessionPickerProps {
|
||||
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;
|
||||
@@ -27,6 +30,7 @@ export function SessionPicker({
|
||||
workspace,
|
||||
onChoose,
|
||||
walletCurrency,
|
||||
pickerPorts,
|
||||
}: SessionPickerProps): React.ReactElement {
|
||||
const [focus, setFocus] = useState(0);
|
||||
const [renaming, setRenaming] = useState<{ from: string; buf: string } | null>(null);
|
||||
@@ -34,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 });
|
||||
|
||||
+120
-61
@@ -1,21 +1,17 @@
|
||||
/**
|
||||
* First-run / re-configure wizard.
|
||||
*
|
||||
* Walks a new user through: API key → preset pick → MCP server pick →
|
||||
* per-server args → save. Saved output lives in `~/.reasonix/config.json`
|
||||
* so the next `reasonix chat` starts with everything already wired.
|
||||
*
|
||||
* The wizard is the antidote to "too many CLI flags" — a new user should
|
||||
* never have to read `--help` to get MCP + a sensible model combo
|
||||
* working. Everything a user could set via `--mcp`, `--harvest`,
|
||||
* `--branch`, etc. can be picked here in a few arrow-key presses.
|
||||
* Walks a new user through: language → API key → preset pick → MCP server
|
||||
* pick → per-server args → save. Saved output lives in
|
||||
* `~/.reasonix/config.json` so the next `reasonix chat` starts with
|
||||
* everything already wired.
|
||||
*/
|
||||
|
||||
import { mkdirSync, statSync } from "node:fs";
|
||||
import { Box, Text, useApp, useInput } from "ink";
|
||||
import TextInput from "ink-text-input";
|
||||
// biome-ignore lint/style/useImportType: JSX (jsx: "react") needs React as a value at runtime
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type PresetName,
|
||||
type ReasonixConfig,
|
||||
@@ -25,6 +21,16 @@ import {
|
||||
redactKey,
|
||||
writeConfig,
|
||||
} from "../../config.js";
|
||||
import {
|
||||
detectSystemLanguage,
|
||||
getLanguage,
|
||||
getSupportedLanguages,
|
||||
notifyLanguageChange,
|
||||
onLanguageChange,
|
||||
setLanguage,
|
||||
t,
|
||||
} from "../../i18n/index.js";
|
||||
import type { LanguageCode } from "../../i18n/types.js";
|
||||
import { type CatalogEntry, MCP_CATALOG } from "../../mcp/catalog.js";
|
||||
import { MultiSelect, type SelectItem, SingleSelect } from "./Select.js";
|
||||
import { PRESET_DESCRIPTIONS } from "./presets.js";
|
||||
@@ -43,22 +49,31 @@ export interface WizardProps {
|
||||
};
|
||||
}
|
||||
|
||||
type Step = "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
|
||||
type Step = "language" | "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
|
||||
|
||||
interface WizardData {
|
||||
language: LanguageCode;
|
||||
apiKey: string;
|
||||
preset: PresetName;
|
||||
selectedCatalog: string[]; // entries from MCP_CATALOG by `name`
|
||||
/** Captured user inputs per catalog entry that has `userArgs` (e.g. fs dir). */
|
||||
selectedCatalog: string[];
|
||||
catalogArgs: Record<string, string>;
|
||||
}
|
||||
|
||||
const CATALOG_BY_NAME = new Map(MCP_CATALOG.map((e) => [e.name, e]));
|
||||
|
||||
const LANGUAGE_LABELS: Record<LanguageCode, string> = {
|
||||
EN: "English",
|
||||
"zh-CN": "简体中文",
|
||||
};
|
||||
|
||||
export function Wizard({ onComplete, onCancel, existingApiKey, initial }: WizardProps) {
|
||||
const { exit } = useApp();
|
||||
const [step, setStep] = useState<Step>(existingApiKey ? "preset" : "apiKey");
|
||||
const [, setLanguageVersion] = useState(0);
|
||||
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
|
||||
|
||||
const [step, setStep] = useState<Step>("language");
|
||||
const [data, setData] = useState<WizardData>({
|
||||
language: getLanguage(),
|
||||
apiKey: existingApiKey ?? "",
|
||||
preset: initial?.preset ?? "auto",
|
||||
selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
|
||||
@@ -66,12 +81,24 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Global Esc → cancel. Disabled once we've started saving to avoid
|
||||
// ejecting out of a half-written state.
|
||||
useInput((_input, key) => {
|
||||
if (key.escape && step !== "saved" && onCancel) onCancel();
|
||||
});
|
||||
|
||||
if (step === "language") {
|
||||
return (
|
||||
<LanguageStep
|
||||
initialValue={data.language}
|
||||
onSubmit={(lang) => {
|
||||
setLanguage(lang);
|
||||
notifyLanguageChange();
|
||||
setData((d) => ({ ...d, language: lang }));
|
||||
setStep(existingApiKey ? "preset" : "apiKey");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "apiKey") {
|
||||
return (
|
||||
<ApiKeyStep
|
||||
@@ -88,7 +115,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
|
||||
if (step === "preset") {
|
||||
return (
|
||||
<StepFrame title="Pick a preset" step={1} total={3}>
|
||||
<StepFrame title={t("wizard.presetTitle")} step={1} total={3}>
|
||||
<SingleSelect<PresetName>
|
||||
items={presetItems()}
|
||||
initialValue={data.preset}
|
||||
@@ -98,7 +125,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
}}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] navigate · [Enter] confirm · [Esc] cancel</Text>
|
||||
<Text dimColor>{t("wizard.selectFooter")}</Text>
|
||||
</Box>
|
||||
</StepFrame>
|
||||
);
|
||||
@@ -106,17 +133,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
|
||||
if (step === "mcp") {
|
||||
return (
|
||||
<StepFrame title="Which MCP servers should Reasonix wire up for you?" step={2} total={3}>
|
||||
<StepFrame title={t("wizard.mcpTitle")} step={2} total={3}>
|
||||
<MultiSelect
|
||||
items={mcpItems()}
|
||||
initialSelected={data.selectedCatalog}
|
||||
onSubmit={(selected) => {
|
||||
setData((d) => ({ ...d, selectedCatalog: selected }));
|
||||
// Only advance to the args step if any selected entry needs args.
|
||||
const needsArgs = selected.some((name) => CATALOG_BY_NAME.get(name)?.userArgs);
|
||||
setStep(needsArgs ? "mcpArgs" : "review");
|
||||
}}
|
||||
footer="[↑↓] navigate · [Space] toggle · [Enter] confirm · [Esc] cancel · empty = skip"
|
||||
footer={t("wizard.mcpFooterMulti")}
|
||||
/>
|
||||
</StepFrame>
|
||||
);
|
||||
@@ -152,13 +178,21 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
if (step === "review") {
|
||||
const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
|
||||
return (
|
||||
<StepFrame title="Ready to save" step={3} total={3}>
|
||||
<StepFrame title={t("wizard.reviewTitle")} step={3} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<SummaryLine label="API key" value={redactKey(data.apiKey)} />
|
||||
<SummaryLine label="Preset" value={data.preset} />
|
||||
<SummaryLine
|
||||
label="MCP"
|
||||
value={specs.length === 0 ? "(none)" : `${specs.length} server(s)`}
|
||||
label={t("wizard.reviewLabelLanguage")}
|
||||
value={LANGUAGE_LABELS[data.language]}
|
||||
/>
|
||||
<SummaryLine label={t("wizard.reviewLabelApiKey")} value={redactKey(data.apiKey)} />
|
||||
<SummaryLine label={t("wizard.reviewLabelPreset")} value={data.preset} />
|
||||
<SummaryLine
|
||||
label={t("wizard.reviewLabelMcp")}
|
||||
value={
|
||||
specs.length === 0
|
||||
? t("wizard.reviewMcpNone")
|
||||
: t("wizard.reviewMcpServers", { count: specs.length })
|
||||
}
|
||||
/>
|
||||
{specs.map((spec, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: review-only render, order fixed
|
||||
@@ -167,7 +201,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text>Saves to {defaultConfigPath()}</Text>
|
||||
<Text>{t("wizard.reviewSavesTo", { path: defaultConfigPath() })}</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
@@ -175,7 +209,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[Enter] save · [Esc] cancel</Text>
|
||||
<Text dimColor>{t("wizard.reviewFooter")}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<ReviewConfirm
|
||||
@@ -196,7 +230,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
setStep("saved");
|
||||
onComplete(next);
|
||||
} catch (e) {
|
||||
setError(`Could not save config: ${(e as Error).message}`);
|
||||
setError(t("wizard.reviewSaveError", { message: (e as Error).message }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -204,17 +238,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
);
|
||||
}
|
||||
|
||||
// saved
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
|
||||
<Text bold color="green">
|
||||
▸ Saved.
|
||||
{t("wizard.savedTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Run `reasonix` any time to start chatting — your settings are remembered.</Text>
|
||||
<Text>{t("ui.welcome")}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[Enter] to exit</Text>
|
||||
<Text dimColor>{t("wizard.savedFooter")}</Text>
|
||||
</Box>
|
||||
<ExitOnEnter onExit={exit} />
|
||||
</Box>
|
||||
@@ -223,6 +256,38 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
|
||||
// ---------- step components ----------
|
||||
|
||||
function LanguageStep({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialValue: LanguageCode;
|
||||
onSubmit: (lang: LanguageCode) => void;
|
||||
}) {
|
||||
const items: SelectItem<LanguageCode>[] = getSupportedLanguages().map((code) => ({
|
||||
value: code,
|
||||
label: LANGUAGE_LABELS[code],
|
||||
hint: code === detectSystemLanguage() ? "(detected)" : undefined,
|
||||
}));
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
{t("wizard.languageTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.languageSubtitle")}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<SingleSelect<LanguageCode>
|
||||
items={items}
|
||||
initialValue={initialValue}
|
||||
onSubmit={onSubmit}
|
||||
footer={t("wizard.selectFooter")}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyStep({
|
||||
onSubmit,
|
||||
error,
|
||||
@@ -236,16 +301,16 @@ function ApiKeyStep({
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
Welcome to Reasonix.
|
||||
{t("wizard.welcomeTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Paste your DeepSeek API key to get started.</Text>
|
||||
<Text>{t("wizard.apiKeyPrompt")}</Text>
|
||||
</Box>
|
||||
<Text dimColor>Get one at: https://platform.deepseek.com/api_keys</Text>
|
||||
<Text dimColor>Saved locally to {defaultConfigPath()}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeyGetOne")}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeySavedLocally", { path: defaultConfigPath() })}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text bold color="cyan">
|
||||
{"key › "}
|
||||
{t("wizard.apiKeyInputLabel")}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
@@ -253,7 +318,7 @@ function ApiKeyStep({
|
||||
onSubmit={(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!isPlausibleKey(trimmed)) {
|
||||
onError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
|
||||
onError(t("wizard.apiKeyInvalid"));
|
||||
setValue("");
|
||||
return;
|
||||
}
|
||||
@@ -269,7 +334,7 @@ function ApiKeyStep({
|
||||
</Box>
|
||||
) : value ? (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>preview: {redactKey(value)}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeyPreview", { redacted: redactKey(value) })}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -302,7 +367,12 @@ function McpArgsStep({
|
||||
onError(null);
|
||||
onSubmit(created);
|
||||
} catch (e) {
|
||||
onError(`Couldn't create ${pendingCreate}: ${(e as Error).message}`);
|
||||
onError(
|
||||
t("wizard.mcpArgsDirCreateFailed", {
|
||||
path: pendingCreate,
|
||||
message: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
setPendingCreate(null);
|
||||
}
|
||||
} else if (ch === "n" || key.escape) {
|
||||
@@ -313,13 +383,11 @@ function McpArgsStep({
|
||||
|
||||
if (pendingCreate) {
|
||||
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>
|
||||
Directory <Text bold>{pendingCreate}</Text> doesn't exist.
|
||||
</Text>
|
||||
<Text>{t("wizard.mcpArgsDirMissing", { path: pendingCreate })}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[Y/Enter] create it (mkdir -p) · [N/Esc] enter a different path</Text>
|
||||
<Text dimColor>{t("wizard.mcpArgsDirCreateHint")}</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
@@ -332,7 +400,7 @@ function McpArgsStep({
|
||||
}
|
||||
|
||||
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 ? (
|
||||
@@ -341,7 +409,7 @@ function McpArgsStep({
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text>Required parameter: </Text>
|
||||
<Text>{t("wizard.mcpArgsRequiredParam")}</Text>
|
||||
<Text bold>{entry.userArgs}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
@@ -355,7 +423,7 @@ 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") {
|
||||
@@ -365,7 +433,7 @@ function McpArgsStep({
|
||||
return;
|
||||
}
|
||||
if (check.kind === "not-a-dir") {
|
||||
onError(`${trimmed} exists but is not a directory.`);
|
||||
onError(t("wizard.mcpArgsNotADir", { path: trimmed }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -407,8 +475,6 @@ function ExitOnEnter({ onExit }: { onExit: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- small bits ----------
|
||||
|
||||
function StepFrame({
|
||||
title,
|
||||
step,
|
||||
@@ -423,9 +489,7 @@ function StepFrame({
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Step {step}/{total} ·{" "}
|
||||
</Text>
|
||||
<Text dimColor>{t("wizard.stepCounter", { step, total })}</Text>
|
||||
<Text bold color="cyan">
|
||||
{title}
|
||||
</Text>
|
||||
@@ -446,8 +510,6 @@ function SummaryLine({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- data helpers ----------
|
||||
|
||||
function presetItems(): SelectItem<PresetName>[] {
|
||||
return (["auto", "flash", "pro"] as const).map((name) => ({
|
||||
value: name as PresetName,
|
||||
@@ -459,7 +521,7 @@ function presetItems(): SelectItem<PresetName>[] {
|
||||
function mcpItems(): SelectItem<string>[] {
|
||||
return MCP_CATALOG.map((entry) => {
|
||||
const hintParts: string[] = [entry.summary];
|
||||
if (entry.userArgs) hintParts.push(`(you'll provide ${entry.userArgs})`);
|
||||
if (entry.userArgs) hintParts.push(t("wizard.mcpUserArgsHint", { arg: entry.userArgs }));
|
||||
if (entry.note) hintParts.push(entry.note);
|
||||
return {
|
||||
value: entry.name,
|
||||
@@ -476,9 +538,6 @@ function placeholderFor(entry: CatalogEntry): string {
|
||||
}
|
||||
|
||||
function deriveInitialCatalog(existingSpecs: string[]): string[] {
|
||||
// Best-effort recovery: if the user previously picked catalog entries,
|
||||
// the spec strings look like `name=npx -y <pkg> ...`. Match by package
|
||||
// so reconfigure pre-checks the same boxes.
|
||||
const packageToName = new Map(MCP_CATALOG.map((e) => [e.package, e.name]));
|
||||
const out: string[] = [];
|
||||
for (const spec of existingSpecs) {
|
||||
@@ -499,7 +558,7 @@ 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}`;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type {
|
||||
DashboardEvent,
|
||||
PickerAction,
|
||||
PickerItem,
|
||||
PickerResolution,
|
||||
} from "../../../server/context.js";
|
||||
|
||||
export interface PickerSnapshot {
|
||||
pickerKind: string;
|
||||
title: string;
|
||||
query?: string;
|
||||
items: PickerItem[];
|
||||
actions: PickerAction[];
|
||||
hasMore?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ViewerSnapshot {
|
||||
viewerKind: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
steps?: Array<{ id: string; title: string; status: "done" | "queued" }>;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface ViewerBroadcastPorts {
|
||||
broadcast: (ev: DashboardEvent) => void;
|
||||
resolverRef: MutableRefObject<(() => void) | null>;
|
||||
snapshotRef: MutableRefObject<ViewerSnapshot | null>;
|
||||
}
|
||||
|
||||
/** Read-only sibling of `usePickerBroadcast` — viewer modals carry no selection so only `close` flows back. */
|
||||
export function useViewerBroadcast(
|
||||
active: boolean,
|
||||
snapshot: ViewerSnapshot,
|
||||
onClose: () => void,
|
||||
ports: ViewerBroadcastPorts,
|
||||
): void {
|
||||
const { broadcast, resolverRef, snapshotRef } = ports;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return () => {
|
||||
broadcast({ kind: "modal-down", modalKind: "viewer" });
|
||||
if (resolverRef.current) resolverRef.current = null;
|
||||
if (snapshotRef.current) snapshotRef.current = null;
|
||||
};
|
||||
}, [active, broadcast, resolverRef, snapshotRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
snapshotRef.current = snapshot;
|
||||
resolverRef.current = onClose;
|
||||
broadcast({ kind: "modal-up", modal: { kind: "viewer", ...snapshot } });
|
||||
}, [active, snapshot, onClose, broadcast, resolverRef, snapshotRef]);
|
||||
}
|
||||
|
||||
export interface PickerBroadcastPorts {
|
||||
broadcast: (ev: DashboardEvent) => void;
|
||||
resolverRef: MutableRefObject<((res: PickerResolution) => void) | null>;
|
||||
snapshotRef: MutableRefObject<PickerSnapshot | null>;
|
||||
}
|
||||
|
||||
/** Mirrors a TUI picker into the dashboard via modal-up/down events. Caller passes stable refs from App.tsx so identity does not churn the effect. */
|
||||
export function usePickerBroadcast(
|
||||
active: boolean,
|
||||
snapshot: PickerSnapshot,
|
||||
onResolve: (res: PickerResolution) => void,
|
||||
ports: PickerBroadcastPorts,
|
||||
): void {
|
||||
const { broadcast, resolverRef, snapshotRef } = ports;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return () => {
|
||||
broadcast({ kind: "modal-down", modalKind: "picker" });
|
||||
if (resolverRef.current) resolverRef.current = null;
|
||||
if (snapshotRef.current) snapshotRef.current = null;
|
||||
};
|
||||
}, [active, broadcast, resolverRef, snapshotRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
snapshotRef.current = snapshot;
|
||||
resolverRef.current = onResolve;
|
||||
broadcast({ kind: "modal-up", modal: { kind: "picker", ...snapshot } });
|
||||
}, [active, snapshot, onResolve, broadcast, resolverRef, snapshotRef]);
|
||||
}
|
||||
@@ -72,11 +72,11 @@ export interface Scrollback {
|
||||
): void;
|
||||
endBranch(id: string, aborted?: boolean): void;
|
||||
|
||||
startReasoning(): string;
|
||||
startReasoning(model?: string): string;
|
||||
appendReasoning(id: string, chunk: string): void;
|
||||
endReasoning(id: string, paragraphs: number, tokens: number, aborted?: boolean): void;
|
||||
|
||||
startStreaming(): string;
|
||||
startStreaming(model?: string): string;
|
||||
appendStreaming(id: string, chunk: string): void;
|
||||
endStreaming(id: string, aborted?: boolean): void;
|
||||
|
||||
@@ -249,9 +249,9 @@ export function useScrollback(): Scrollback {
|
||||
endBranch(id, aborted) {
|
||||
dispatch({ type: "branch.end", id, aborted });
|
||||
},
|
||||
startReasoning() {
|
||||
startReasoning(model) {
|
||||
const id = nextId("r");
|
||||
dispatch({ type: "reasoning.start", id });
|
||||
dispatch({ type: "reasoning.start", id, ...(model ? { model } : {}) });
|
||||
return id;
|
||||
},
|
||||
appendReasoning(id, chunk) {
|
||||
@@ -260,9 +260,9 @@ export function useScrollback(): Scrollback {
|
||||
endReasoning(id, paragraphs, tokens, aborted) {
|
||||
dispatch({ type: "reasoning.end", id, paragraphs, tokens, aborted });
|
||||
},
|
||||
startStreaming() {
|
||||
startStreaming(model) {
|
||||
const id = nextId("s");
|
||||
dispatch({ type: "streaming.start", id });
|
||||
dispatch({ type: "streaming.start", id, ...(model ? { model } : {}) });
|
||||
return id;
|
||||
},
|
||||
appendStreaming(id, chunk) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform)
|
||||
import React from "react";
|
||||
import type { ApplyResult } from "../../../code/edit-blocks.js";
|
||||
@@ -80,17 +80,7 @@ export function ModeStatusBar({
|
||||
}
|
||||
|
||||
function ModeBarFrame({ children }: { children: React.ReactNode }) {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const ruleWidth = Math.max(20, cols - 2);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1}>
|
||||
<Text color={FG.faint}>{"╌".repeat(ruleWidth)}</Text>
|
||||
</Box>
|
||||
<Box paddingX={1}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
return <Box paddingX={1}>{children}</Box>;
|
||||
}
|
||||
|
||||
function ModePill({
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { Countdown } from "../primitives/Countdown.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
import type { Mode, NetworkState, StatusBar } from "../state/state.js";
|
||||
import { FG, TONE, balanceColor, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
import { FG, TONE, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const RULE_PAD = 4;
|
||||
const RULE_MIN = 20;
|
||||
@@ -46,19 +46,6 @@ export function StatusRow(): React.ReactElement {
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
<Text
|
||||
color={FG.sub}
|
||||
>{`${formatCost(status.sessionCost, status.balanceCurrency, 3)} session`}</Text>
|
||||
{status.balance !== undefined && (
|
||||
<>
|
||||
<Sep />
|
||||
<Text color={FG.faint}>{"wallet "}</Text>
|
||||
<Text bold color={balanceColor(status.balance, status.balanceCurrency)}>
|
||||
{formatBalance(status.balance, status.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
<Text color={TONE.accent}>{`cache ${Math.round(status.cacheHit * 100)}%`}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box } from "ink";
|
||||
import React, { useContext } from "react";
|
||||
|
||||
/** Settled cards (in scrollback) drop the border + paddingX so history reads as flat lines. */
|
||||
/** Settled cards (in scrollback) drop border + padding + margin so history collapses to flat lines. */
|
||||
export const ActiveCardContext = React.createContext(true);
|
||||
|
||||
export interface CardProps {
|
||||
@@ -23,11 +23,7 @@ const STRIPE_BORDER = {
|
||||
export function Card({ tone, children }: CardProps): React.ReactElement {
|
||||
const active = useContext(ActiveCardContext);
|
||||
if (!active) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
return <Box flexDirection="column">{children}</Box>;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import React, { useContext } from "react";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
import { ActiveCardContext } from "./Card.js";
|
||||
|
||||
export type MetaItem = string | { text: string; color: string };
|
||||
|
||||
@@ -30,6 +31,9 @@ export function CardHeader({
|
||||
meta,
|
||||
right,
|
||||
}: CardHeaderProps): React.ReactElement {
|
||||
const active = useContext(ActiveCardContext);
|
||||
// Settled scrollback drops faint string meta + spinners; colored badges (rejected, retry) stay.
|
||||
const visibleMeta = active ? meta : meta?.filter((item) => typeof item !== "string");
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={tone}>{glyph}</Text>
|
||||
@@ -43,7 +47,7 @@ export function CardHeader({
|
||||
</Text>
|
||||
)}
|
||||
{subtitle ? <Text color={FG.body}>{subtitle}</Text> : null}
|
||||
{meta?.map((item, i) => {
|
||||
{visibleMeta?.map((item, i) => {
|
||||
const isStr = typeof item === "string";
|
||||
const text = isStr ? item : item.text;
|
||||
const color = isStr ? FG.faint : item.color;
|
||||
@@ -55,7 +59,7 @@ export function CardHeader({
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{right}
|
||||
{active ? right : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ const restore: SlashHandler = (args, _loop, ctx) => {
|
||||
}
|
||||
const target = args.join(" ").trim();
|
||||
if (!target) {
|
||||
return { info: t("handlers.edits.restoreUsage") };
|
||||
return { openCheckpointPicker: true };
|
||||
}
|
||||
const found = findCheckpoint(ctx.codeRoot, target);
|
||||
if (!found) {
|
||||
|
||||
@@ -85,17 +85,15 @@ function toggleDisabled(
|
||||
): { info: string } {
|
||||
const name = rawName?.trim();
|
||||
if (!name) {
|
||||
return {
|
||||
info: `usage: /mcp ${action} <name> · pick a name shown in /mcp (anonymous servers can't be named-toggled).`,
|
||||
};
|
||||
return { info: t("handlers.mcp.usageDisableEnable", { action }) };
|
||||
}
|
||||
const known = new Set<string>([
|
||||
...ctx.servers.map((s) => s.label),
|
||||
...ctx.specs.map((spec) => parseLabelFromSpec(spec)).filter((n): n is string => n !== null),
|
||||
]);
|
||||
if (!known.has(name)) {
|
||||
const list = [...known].sort().join(", ") || "(none)";
|
||||
return { info: `unknown MCP server "${name}". Known: ${list}.` };
|
||||
const list = [...known].sort().join(", ") || t("handlers.mcp.noneList");
|
||||
return { info: t("handlers.mcp.unknownServer", { name, list }) };
|
||||
}
|
||||
return { info: toggleMcpDisabled(action, name) };
|
||||
}
|
||||
@@ -113,20 +111,19 @@ function triggerReconnect(
|
||||
): { info: string } {
|
||||
const name = rawName?.trim();
|
||||
if (!name) {
|
||||
return {
|
||||
info: "usage: /mcp reconnect <name> · pick a name shown in /mcp.",
|
||||
};
|
||||
return { info: t("handlers.mcp.usageReconnect") };
|
||||
}
|
||||
const target = servers.find((s) => s.label === name);
|
||||
if (!target) {
|
||||
const list = servers
|
||||
.map((s) => s.label)
|
||||
.sort()
|
||||
.join(", ");
|
||||
return { info: `unknown MCP server "${name}". Known: ${list || "(none)"}.` };
|
||||
const list =
|
||||
servers
|
||||
.map((s) => s.label)
|
||||
.sort()
|
||||
.join(", ") || t("handlers.mcp.noneList");
|
||||
return { info: t("handlers.mcp.unknownServer", { name, list }) };
|
||||
}
|
||||
if (!postInfo) {
|
||||
return { info: "/mcp reconnect requires the interactive TUI (postInfo not wired)." };
|
||||
return { info: t("handlers.mcp.reconnectNoTui") };
|
||||
}
|
||||
// Append-drift accepted automatically: server added new tools, we register them
|
||||
// and call addTool on the prefix (cache miss only on the appended chunks per the
|
||||
|
||||
@@ -40,10 +40,16 @@ const plans: SlashHandler = (_args, loop) => {
|
||||
const when = relativeTime(a.completedAt);
|
||||
const total = a.steps.length;
|
||||
const done = a.completedStepIds.length;
|
||||
const completion = done >= total ? "complete" : `${done}/${total}`;
|
||||
const completion = done >= total ? t("handlers.plans.completionComplete") : `${done}/${total}`;
|
||||
const label = a.summary ?? a.path.split(/[\\/]/).pop() ?? a.path;
|
||||
lines.push(
|
||||
` ✓ ${when.padEnd(10)} ${total} step${total === 1 ? "" : "s"} · ${completion} ${label}`,
|
||||
t("handlers.plans.archivedRow", {
|
||||
when: when.padEnd(10),
|
||||
total,
|
||||
s: total === 1 ? "" : "s",
|
||||
completion,
|
||||
label,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { info: lines.join("\n") };
|
||||
@@ -83,9 +89,7 @@ const replay: SlashHandler = (args, loop) => {
|
||||
|
||||
const stop: SlashHandler = (_args, loop) => {
|
||||
loop.abort();
|
||||
return {
|
||||
info: "▸ plan stopped — model aborted; type a follow-up to continue or start a new task.",
|
||||
};
|
||||
return { info: t("handlers.plans.stopAborted") };
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { t as tMain } from "@/i18n/index.js";
|
||||
import { probeOllama } from "@/index/semantic/embedding.js";
|
||||
import { t } from "@/index/semantic/i18n.js";
|
||||
import { findOllamaBinary } from "@/index/semantic/ollama-launcher.js";
|
||||
@@ -10,9 +11,7 @@ import type { SlashHandler } from "../dispatch.js";
|
||||
const semantic: SlashHandler = (_args, _loop, ctx) => {
|
||||
const root = ctx.codeRoot;
|
||||
if (!root) {
|
||||
return {
|
||||
info: "/semantic is only available inside `reasonix code` (needs a project root).",
|
||||
};
|
||||
return { info: tMain("handlers.semantic.codeOnly") };
|
||||
}
|
||||
// Fire-and-forget: probes (file stat, optional Ollama HTTP) take
|
||||
// ~50–200ms which is too long to block the prompt. Same pattern
|
||||
@@ -23,7 +22,7 @@ const semantic: SlashHandler = (_args, _loop, ctx) => {
|
||||
const status = await renderSemanticStatus(root);
|
||||
ctx.postInfo?.(status);
|
||||
})();
|
||||
return { info: "▸ checking semantic_search status…" };
|
||||
return { info: tMain("handlers.semantic.checking") };
|
||||
};
|
||||
|
||||
export async function renderSemanticStatus(rootDir: string): Promise<string> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readConfig, webSearchEndpoint, webSearchEngine, writeConfig } from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
@@ -7,18 +8,18 @@ export const handlers: Record<string, SlashHandler> = {
|
||||
if (!engine || (engine !== "mojeek" && engine !== "searxng")) {
|
||||
return {
|
||||
info: [
|
||||
`Current web search engine: ${webSearchEngine()}`,
|
||||
`SearXNG endpoint: ${webSearchEndpoint()}`,
|
||||
t("handlers.webSearchEngine.currentEngine", { engine: webSearchEngine() }),
|
||||
t("handlers.webSearchEngine.endpoint", { url: webSearchEndpoint() }),
|
||||
"",
|
||||
"Usage:",
|
||||
" /search-engine mojeek use Mojeek (default, no external deps)",
|
||||
" /search-engine searxng use SearXNG at default endpoint",
|
||||
" /search-engine searxng <url> use SearXNG at custom endpoint",
|
||||
t("handlers.webSearchEngine.usageHeader"),
|
||||
t("handlers.webSearchEngine.usageMojeek"),
|
||||
t("handlers.webSearchEngine.usageSearxng"),
|
||||
t("handlers.webSearchEngine.usageSearxngUrl"),
|
||||
"",
|
||||
"Alias: /se",
|
||||
t("handlers.webSearchEngine.alias"),
|
||||
"",
|
||||
"SearXNG is a self-hosted metasearch engine (https://github.com/searxng/searxng).",
|
||||
"Install it with: docker run -d -p 8080:8080 searxng/searxng",
|
||||
t("handlers.webSearchEngine.searxngInfo"),
|
||||
t("handlers.webSearchEngine.searxngInstall"),
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
@@ -31,13 +32,17 @@ export const handlers: Record<string, SlashHandler> = {
|
||||
}
|
||||
writeConfig(cfg);
|
||||
|
||||
ctx.postInfo?.(
|
||||
`Switched web search engine to "${engine}". ${engine === "searxng" ? `Make sure SearXNG is running at ${webSearchEndpoint()}.` : ""}`,
|
||||
);
|
||||
const note =
|
||||
engine === "searxng"
|
||||
? t("handlers.webSearchEngine.switchedSearxngNote", { endpoint: webSearchEndpoint() })
|
||||
: "";
|
||||
ctx.postInfo?.(t("handlers.webSearchEngine.switched", { engine, note }));
|
||||
|
||||
return {
|
||||
info: `✓ Web search engine set to "${engine}"${engine === "searxng" ? ` (${webSearchEndpoint()})` : ""}. Next assistant turn will pick up the change.`,
|
||||
};
|
||||
const detail =
|
||||
engine === "searxng"
|
||||
? t("handlers.webSearchEngine.confirmedDetail", { endpoint: webSearchEndpoint() })
|
||||
: "";
|
||||
return { info: t("handlers.webSearchEngine.confirmed", { engine, detail }) };
|
||||
},
|
||||
se: (args, loop, ctx) => handlers["search-engine"]!(args, loop, ctx),
|
||||
};
|
||||
@@ -10,6 +10,8 @@ export interface SlashResult {
|
||||
info?: string;
|
||||
/** Open the SessionPicker modal mid-chat — used by `/sessions` slash. */
|
||||
openSessionsPicker?: boolean;
|
||||
/** Open the CheckpointPicker modal — bare `/restore` (no name argument). */
|
||||
openCheckpointPicker?: boolean;
|
||||
/** Open the ModelPicker modal — bare `/model` (no id) opens it. */
|
||||
openModelPicker?: boolean;
|
||||
/** Open the unified MCP hub — `/mcp` defaults to "live", `/mcp browse` to "marketplace". */
|
||||
|
||||
@@ -9,13 +9,13 @@ export class TurnTranslator {
|
||||
|
||||
constructor(private readonly log: Scrollback) {}
|
||||
|
||||
flushBuffers(reasoningChunk: string, contentChunk: string): void {
|
||||
flushBuffers(reasoningChunk: string, contentChunk: string, model?: string): void {
|
||||
if (reasoningChunk) {
|
||||
if (!this.reasoningCardId) this.reasoningCardId = this.log.startReasoning();
|
||||
if (!this.reasoningCardId) this.reasoningCardId = this.log.startReasoning(model);
|
||||
this.log.appendReasoning(this.reasoningCardId, reasoningChunk);
|
||||
}
|
||||
if (contentChunk) {
|
||||
if (!this.streamingCardId) this.streamingCardId = this.log.startStreaming();
|
||||
if (!this.streamingCardId) this.streamingCardId = this.log.startStreaming(model);
|
||||
this.log.appendStreaming(this.streamingCardId, contentChunk);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const turnThinking = z.object({
|
||||
const reasoningStart = z.object({
|
||||
type: z.literal("reasoning.start"),
|
||||
id: cardId,
|
||||
model: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const reasoningChunk = z.object({
|
||||
@@ -39,6 +40,7 @@ const reasoningEnd = z.object({
|
||||
const streamingStart = z.object({
|
||||
type: z.literal("streaming.start"),
|
||||
id: cardId,
|
||||
model: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const streamingChunk = z.object({
|
||||
|
||||
@@ -25,7 +25,7 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
|
||||
);
|
||||
|
||||
case "reasoning.start":
|
||||
return appendCard(state, makeReasoningCard(event.id, state.session.model));
|
||||
return appendCard(state, makeReasoningCard(event.id, event.model ?? state.session.model));
|
||||
|
||||
case "reasoning.chunk":
|
||||
return mutateCard(state, event.id, "reasoning", (c) => ({ ...c, text: c.text + event.text }));
|
||||
@@ -41,7 +41,7 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
|
||||
}));
|
||||
|
||||
case "streaming.start":
|
||||
return appendCard(state, makeStreamingCard(event.id, state.session.model));
|
||||
return appendCard(state, makeStreamingCard(event.id, event.model ?? state.session.model));
|
||||
|
||||
case "streaming.chunk":
|
||||
return mutateCard(state, event.id, "streaming", (c) => ({ ...c, text: c.text + event.text }));
|
||||
|
||||
+30
-10
@@ -115,12 +115,27 @@ export function saveApiKey(key: string, path: string = defaultConfigPath()): voi
|
||||
writeConfig(cfg, path);
|
||||
}
|
||||
|
||||
/** Windows: case-insensitive — NTFS treats `F:\Foo` and `f:\foo` as one directory (#402). */
|
||||
function findProjectKey(cfg: ReasonixConfig, rootDir: string): string | undefined {
|
||||
const projects = cfg.projects;
|
||||
if (!projects) return undefined;
|
||||
if (Object.hasOwn(projects, rootDir)) return rootDir;
|
||||
if (process.platform !== "win32") return undefined;
|
||||
const lower = rootDir.toLowerCase();
|
||||
for (const k of Object.keys(projects)) {
|
||||
if (k.toLowerCase() === lower) return k;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function loadProjectShellAllowed(
|
||||
rootDir: string,
|
||||
path: string = defaultConfigPath(),
|
||||
): string[] {
|
||||
const cfg = readConfig(path);
|
||||
return cfg.projects?.[rootDir]?.shellAllowed ?? [];
|
||||
const key = findProjectKey(cfg, rootDir);
|
||||
if (key === undefined) return [];
|
||||
return cfg.projects?.[key]?.shellAllowed ?? [];
|
||||
}
|
||||
|
||||
export function addProjectShellAllowed(
|
||||
@@ -132,10 +147,11 @@ export function addProjectShellAllowed(
|
||||
if (!trimmed) return;
|
||||
const cfg = readConfig(path);
|
||||
if (!cfg.projects) cfg.projects = {};
|
||||
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
|
||||
const existing = cfg.projects[rootDir].shellAllowed ?? [];
|
||||
const key = findProjectKey(cfg, rootDir) ?? rootDir;
|
||||
if (!cfg.projects[key]) cfg.projects[key] = {};
|
||||
const existing = cfg.projects[key].shellAllowed ?? [];
|
||||
if (existing.includes(trimmed)) return;
|
||||
cfg.projects[rootDir].shellAllowed = [...existing, trimmed];
|
||||
cfg.projects[key].shellAllowed = [...existing, trimmed];
|
||||
writeConfig(cfg, path);
|
||||
}
|
||||
|
||||
@@ -148,12 +164,14 @@ export function removeProjectShellAllowed(
|
||||
const trimmed = prefix.trim();
|
||||
if (!trimmed) return false;
|
||||
const cfg = readConfig(path);
|
||||
const existing = cfg.projects?.[rootDir]?.shellAllowed ?? [];
|
||||
const key = findProjectKey(cfg, rootDir);
|
||||
if (key === undefined) return false;
|
||||
const existing = cfg.projects?.[key]?.shellAllowed ?? [];
|
||||
if (!existing.includes(trimmed)) return false;
|
||||
const next = existing.filter((p) => p !== trimmed);
|
||||
if (!cfg.projects) cfg.projects = {};
|
||||
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
|
||||
cfg.projects[rootDir].shellAllowed = next;
|
||||
if (!cfg.projects[key]) cfg.projects[key] = {};
|
||||
cfg.projects[key].shellAllowed = next;
|
||||
writeConfig(cfg, path);
|
||||
return true;
|
||||
}
|
||||
@@ -163,11 +181,13 @@ export function clearProjectShellAllowed(
|
||||
path: string = defaultConfigPath(),
|
||||
): number {
|
||||
const cfg = readConfig(path);
|
||||
const existing = cfg.projects?.[rootDir]?.shellAllowed ?? [];
|
||||
const key = findProjectKey(cfg, rootDir);
|
||||
if (key === undefined) return 0;
|
||||
const existing = cfg.projects?.[key]?.shellAllowed ?? [];
|
||||
if (existing.length === 0) return 0;
|
||||
if (!cfg.projects) cfg.projects = {};
|
||||
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
|
||||
cfg.projects[rootDir].shellAllowed = [];
|
||||
if (!cfg.projects[key]) cfg.projects[key] = {};
|
||||
cfg.projects[key].shellAllowed = [];
|
||||
writeConfig(cfg, path);
|
||||
return existing.length;
|
||||
}
|
||||
|
||||
+10
-3
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { t } from "./i18n/index.js";
|
||||
|
||||
export type HookEvent = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop";
|
||||
|
||||
@@ -284,9 +285,15 @@ export function formatHookOutcomeMessage(outcome: HookOutcome): string {
|
||||
outcome.hook.command.length > 60
|
||||
? `${outcome.hook.command.slice(0, 60)}…`
|
||||
: outcome.hook.command;
|
||||
const truncTag = outcome.truncated ? " (output truncated at 256KB)" : "";
|
||||
const head = `hook ${tag} \`${cmd}\` ${outcome.decision}${truncTag}`;
|
||||
return detail ? `${head}: ${detail}` : head;
|
||||
const truncTag = outcome.truncated ? t("hooks.truncated") : "";
|
||||
const decision = t(`hooks.decision${capitalize(outcome.decision)}`);
|
||||
return detail
|
||||
? t("hooks.headWithDetail", { tag, cmd, decision, truncTag, detail })
|
||||
: t("hooks.head", { tag, cmd, decision, truncTag });
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
export function decideOutcome(
|
||||
|
||||
+183
-8
@@ -284,15 +284,159 @@ export const EN: TranslationSchema = {
|
||||
argsHint: "<id> [lines]",
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
languageTitle: "Choose your language",
|
||||
languageSubtitle: "Detected from your system locale. Switch later via /language.",
|
||||
welcomeTitle: "Welcome to Reasonix.",
|
||||
apiKeyPrompt: "Paste your DeepSeek API key to get started.",
|
||||
apiKeyGetOne: "Get one at: https://platform.deepseek.com/api_keys",
|
||||
apiKeySavedLocally: "Saved locally to {path}",
|
||||
apiKeyInputLabel: "key › ",
|
||||
apiKeyInvalid: "Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.",
|
||||
apiKeyPreview: "preview: {redacted}",
|
||||
presetTitle: "Pick a preset",
|
||||
mcpTitle: "Which MCP servers should Reasonix wire up for you?",
|
||||
mcpUserArgsHint: "(you'll provide {arg})",
|
||||
mcpFooterMulti:
|
||||
"[↑↓] navigate · [Space] toggle · [Enter] confirm · [Esc] cancel · empty = skip",
|
||||
mcpArgsTitle: "Configure {name}",
|
||||
mcpArgsDirMissing: "Directory {path} doesn't exist.",
|
||||
mcpArgsDirCreateHint: "[Y/Enter] create it (mkdir -p) · [N/Esc] enter a different path",
|
||||
mcpArgsDirCreateFailed: "Couldn't create {path}: {message}",
|
||||
mcpArgsRequiredParam: "Required parameter: ",
|
||||
mcpArgsEmpty: "{name} needs a value — got an empty string.",
|
||||
mcpArgsNotADir: "{path} exists but is not a directory.",
|
||||
reviewTitle: "Ready to save",
|
||||
reviewLabelApiKey: "API key",
|
||||
reviewLabelLanguage: "Language",
|
||||
reviewLabelPreset: "Preset",
|
||||
reviewLabelMcp: "MCP",
|
||||
reviewMcpNone: "(none)",
|
||||
reviewMcpServers: "{count} server(s)",
|
||||
reviewSavesTo: "Saves to {path}",
|
||||
reviewSaveError: "Could not save config: {message}",
|
||||
reviewFooter: "[Enter] save · [Esc] cancel",
|
||||
savedTitle: "▸ Saved.",
|
||||
savedFooter: "[Enter] to exit",
|
||||
selectFooter: "[↑↓] navigate · [Enter] confirm · [Esc] cancel",
|
||||
stepCounter: "Step {step}/{total} · ",
|
||||
},
|
||||
app: {
|
||||
walkCancelledRemaining: "▸ walk cancelled — {count} block(s) still pending.",
|
||||
walkCancelled: "▸ walk cancelled.",
|
||||
editModeYolo:
|
||||
"▸ edit mode: YOLO — edits AND shell commands auto-run. /undo still rolls back edits. Use carefully.",
|
||||
editModeAuto:
|
||||
"▸ edit mode: AUTO — edits apply immediately; press u within 5s to undo (space pauses the timer). Shell commands still ask.",
|
||||
editModeReview: "▸ edit mode: review — edits queue for /apply (or y) / /discard (or n)",
|
||||
rejectedEdit: "▸ rejected edit to {path}{context}",
|
||||
autoApprovingRest: "▸ auto-approving remaining edits for this turn",
|
||||
flippedAutoSession: "▸ flipped to AUTO mode for the rest of the session (persisted)",
|
||||
flippedAutoWalk: "▸ flipped to AUTO mode — future edits will apply immediately. Walk exited.",
|
||||
dashboardStopped: "▸ dashboard stopped.",
|
||||
notedMemory: "▸ noted ({scope}) — {verb} {path}",
|
||||
notedScopeProject: "project",
|
||||
notedScopeGlobal: "global",
|
||||
notedVerbCreated: "created",
|
||||
notedVerbAppended: "appended to",
|
||||
memoryWriteFailed: "# memory write failed",
|
||||
commandFailed: "! command failed",
|
||||
restoreCodeOnly: "▸ /restore is code-mode only",
|
||||
hookUserPromptSubmit: "UserPromptSubmit hook",
|
||||
hookStop: "Stop hook",
|
||||
atMentions: "▸ @mentions: {parts}",
|
||||
atUrl: "▸ @url: {parts}",
|
||||
atUrlFailed: "@url expansion failed",
|
||||
denied: "▸ denied: {cmd}{context}",
|
||||
alwaysAllowed: '▸ always allowed "{prefix}" for {dir}',
|
||||
runningCommand: "▸ running: {cmd}",
|
||||
startingBackground: "▸ starting (background): {cmd}",
|
||||
checkpointSaved:
|
||||
"⛁ checkpoint saved · {id} · {count} file{s} · /restore {id} to roll back this step",
|
||||
continuingAfter: "▸ continuing after {label}{counter}",
|
||||
planStoppedAt: "▸ plan stopped at {label}{counter}",
|
||||
revisingAfter: "▸ revising after {label} — {feedback}",
|
||||
},
|
||||
hooks: {
|
||||
head: "hook {tag} `{cmd}` {decision}{truncTag}",
|
||||
headWithDetail: "hook {tag} `{cmd}` {decision}{truncTag}: {detail}",
|
||||
truncated: " (output truncated at 256KB)",
|
||||
decisionBlock: "block",
|
||||
decisionWarn: "warn",
|
||||
decisionTimeout: "timeout",
|
||||
decisionError: "error",
|
||||
},
|
||||
summary: {
|
||||
status: "summarizing what was gathered…",
|
||||
hallucinatedFallback:
|
||||
"(model emitted fake tool-call markup instead of a prose summary — try /retry with a narrower question, or /think to inspect R1's reasoning)",
|
||||
failedAfterReason:
|
||||
"{label} and the fallback summary call failed: {message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.",
|
||||
},
|
||||
loop: {
|
||||
budgetWarning: "▲ session budget reached 80% ($${used} / $${cap})",
|
||||
budgetRefusal:
|
||||
"▲ session budget reached 100% ($${used} / $${cap}) — turn refused. Bump or clear via /budget.",
|
||||
contextWarning: "▲ context window pressure: {ratio}% ({tokens} / {cap})",
|
||||
escalationWarning: "⇧ flash requested escalation — {reason}",
|
||||
toolFailure: "▸ tool {name} failed: {reason}",
|
||||
interrupted: "▸ turn interrupted by user.",
|
||||
loopStopped: "▸ loop stopped (after {count} iter{s}).",
|
||||
budgetExhausted:
|
||||
"session budget exhausted — spent ${spent} ≥ cap ${cap}. Bump the cap with /budget <usd>, clear it with /budget off, or end the session.",
|
||||
budget80Pct: "▲ budget 80% used — ${spent} of ${cap}. Next turn or two likely trips the cap.",
|
||||
proArmed: "⇧ /pro armed — this turn runs on deepseek-v4-pro (one-shot · disarms after turn)",
|
||||
abortedAtIter:
|
||||
"aborted at iter {iter}/{cap} — stopped without producing a summary (press ↑ + Enter or /retry to resume)",
|
||||
toolUploadStatus: "tool result uploaded · model thinking before next response…",
|
||||
toolBudgetWarning:
|
||||
"{iter}/{cap} tool calls used — approaching budget. Press Esc to force a summary now.",
|
||||
preflightFoldStatus: "preflight: context near full, attempting fold…",
|
||||
preflightFolded:
|
||||
"preflight: request ~{estimate}/{ctxMax} tokens ({pct}%) — folded {beforeMessages} messages → {afterMessages} (summary {summaryChars} chars). Sending.",
|
||||
preflightNoFold:
|
||||
"preflight: request ~{estimate}/{ctxMax} tokens ({pct}%) and nothing left to fold — DeepSeek will likely 400. Run /clear or /new to start fresh.",
|
||||
flashEscalation: "⇧ flash requested escalation — retrying this turn on {model}{reasonSuffix}",
|
||||
harvestStatus: "extracting plan state from reasoning…",
|
||||
autoEscalation:
|
||||
"⇧ auto-escalating to {model} for the rest of this turn — flash hit {breakdown}. Next turn falls back to {fallback} unless /pro is armed.",
|
||||
repeatToolCallWarning:
|
||||
"Caught a repeated tool call — let the model see the issue and retry with a different approach.",
|
||||
stormStuck:
|
||||
"Stopped a stuck retry loop — the model kept calling the same tool with identical args after a self-correction nudge. Try /retry, rephrase, or rule out the underlying blocker.",
|
||||
stormSuppressed: "Suppressed {count} repeated tool call(s) — same name + args fired 3+ times.",
|
||||
compactingHistoryStatus: "compacting history{aggressiveTag}…",
|
||||
aggressiveTag: " (aggressive)",
|
||||
foldedHistory:
|
||||
"context {before}/{ctxMax} ({pct}%) — folded {beforeMessages} messages → {afterMessages} (summary {summaryChars} chars). Continuing.",
|
||||
aggressivelyFoldedHistory:
|
||||
"context {before}/{ctxMax} ({pct}%) — aggressively folded {beforeMessages} messages → {afterMessages} (summary {summaryChars} chars). Continuing.",
|
||||
forcingSummary:
|
||||
"context {before}/{ctxMax} ({pct}%) — forcing summary from what was gathered. Run /compact, /clear, or /new to reset.",
|
||||
},
|
||||
errors: {
|
||||
contextOverflow:
|
||||
"Context overflow (DeepSeek 400): session history is {requested}, past the model's prompt limit (V4: 1M tokens; legacy chat/reasoner: 131k). Usually a single tool result grew too big. Reasonix caps new tool results at 8k tokens and auto-heals oversized history on session load — a restart often clears it. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.",
|
||||
contextOverflowTooMany: "too many tokens",
|
||||
auth401:
|
||||
"Authentication failed (DeepSeek 401): {inner}. Your API key is rejected. Fix with `reasonix setup` or `export DEEPSEEK_API_KEY=sk-...`. Get one at https://platform.deepseek.com/api_keys.",
|
||||
balance402:
|
||||
"Out of balance (DeepSeek 402): {inner}. Top up at https://platform.deepseek.com/top_up — the panel header shows your balance once it's non-zero.",
|
||||
badparam422: "Invalid parameter (DeepSeek 422): {inner}",
|
||||
badrequest400: "Bad request (DeepSeek 400): {inner}",
|
||||
deepseek5xxHead:
|
||||
"DeepSeek service unavailable ({status}) — this is a DeepSeek-side problem, not Reasonix. Already retried 4× with backoff.",
|
||||
deepseek5xxReachable:
|
||||
" DeepSeek's main API answered our health check, but /chat/completions is failing — partial outage on their side.",
|
||||
deepseek5xxUnreachable:
|
||||
" DeepSeek API is unreachable from your network — could be a wider DS outage or a local network issue.",
|
||||
deepseek5xxActionNetwork:
|
||||
" Try: (1) check your network, (2) wait 30s and retry, (3) status page: https://status.deepseek.com.",
|
||||
deepseek5xxActionRetry:
|
||||
" Try: (1) wait 30s and retry, (2) /preset to switch model, (3) status page: https://status.deepseek.com.",
|
||||
innerNoMessage: "(no message)",
|
||||
reasonAborted: "[aborted by user (Esc) — summarizing what I found so far]",
|
||||
reasonContextGuard:
|
||||
"[context budget running low — summarizing before the next call would overflow]",
|
||||
reasonStuck:
|
||||
"[stuck on a repeated tool call — explaining what was tried and what's blocking progress]",
|
||||
reasonBudget: "[tool-call budget ({iterCap}) reached — forcing summary from what I found]",
|
||||
labelAborted: "aborted by user",
|
||||
labelContextGuard: "context-guard triggered (prompt > 80% of window)",
|
||||
labelStuck: "stuck (repeated tool call suppressed by storm-breaker)",
|
||||
labelBudget: "tool-call budget ({iterCap}) reached",
|
||||
},
|
||||
handlers: {
|
||||
basic: {
|
||||
@@ -736,6 +880,10 @@ export const EN: TranslationSchema = {
|
||||
"no archived plans yet for this session — `/replay` lights up once a plan completes (auto-archives when every step is done).",
|
||||
replayInvalidIndex:
|
||||
"invalid index — `/replay` takes 1..{max} (newest = 1). Use `/plans` to see the list.",
|
||||
archivedRow: " ✓ {when} {total} step{s} · {completion} {label}",
|
||||
completionComplete: "complete",
|
||||
stopAborted:
|
||||
"▸ plan stopped — model aborted; type a follow-up to continue or start a new task.",
|
||||
},
|
||||
jobs: {
|
||||
codeOnly: "/jobs is only available inside `reasonix code`.",
|
||||
@@ -807,6 +955,12 @@ export const EN: TranslationSchema = {
|
||||
fallbackServers: "MCP servers ({count}):",
|
||||
fallbackTools: "Tools in registry ({count}):",
|
||||
fallbackChange: "To change this set, exit and run `reasonix setup`.",
|
||||
usageDisableEnable:
|
||||
"usage: /mcp {action} <name> · pick a name shown in /mcp (anonymous servers can't be named-toggled).",
|
||||
usageReconnect: "usage: /mcp reconnect <name> · pick a name shown in /mcp.",
|
||||
unknownServer: 'unknown MCP server "{name}". Known: {list}.',
|
||||
noneList: "(none)",
|
||||
reconnectNoTui: "/mcp reconnect requires the interactive TUI (postInfo not wired).",
|
||||
},
|
||||
init: {
|
||||
codeOnly:
|
||||
@@ -817,6 +971,27 @@ export const EN: TranslationSchema = {
|
||||
existsPinned: " pinned into the system prompt every launch as-is.",
|
||||
info: "▸ /init — model will scan the project and synthesize REASONIX.md.\n The result lands as a pending edit; review with /apply or /walk.",
|
||||
},
|
||||
semantic: {
|
||||
codeOnly: "/semantic is only available inside `reasonix code` (needs a project root).",
|
||||
checking: "▸ checking semantic_search status…",
|
||||
},
|
||||
webSearchEngine: {
|
||||
currentEngine: "Current web search engine: {engine}",
|
||||
endpoint: "SearXNG endpoint: {url}",
|
||||
usageHeader: "Usage:",
|
||||
usageMojeek: " /search-engine mojeek use Mojeek (default, no external deps)",
|
||||
usageSearxng: " /search-engine searxng use SearXNG at default endpoint",
|
||||
usageSearxngUrl: " /search-engine searxng <url> use SearXNG at custom endpoint",
|
||||
alias: "Alias: /se",
|
||||
searxngInfo:
|
||||
"SearXNG is a self-hosted metasearch engine (https://github.com/searxng/searxng).",
|
||||
searxngInstall: "Install it with: docker run -d -p 8080:8080 searxng/searxng",
|
||||
switched: 'Switched web search engine to "{engine}".{note}',
|
||||
switchedSearxngNote: " Make sure SearXNG is running at {endpoint}.",
|
||||
confirmed:
|
||||
'✓ Web search engine set to "{engine}"{detail}. Next assistant turn will pick up the change.',
|
||||
confirmedDetail: " ({endpoint})",
|
||||
},
|
||||
skill: {
|
||||
listEmpty: "no skills found. Reasonix reads skills from:",
|
||||
listProjectScope:
|
||||
|
||||
+125
-7
@@ -124,18 +124,136 @@ export interface TranslationSchema {
|
||||
unsupported?: string;
|
||||
};
|
||||
};
|
||||
app: {
|
||||
walkCancelledRemaining: string;
|
||||
walkCancelled: string;
|
||||
editModeYolo: string;
|
||||
editModeAuto: string;
|
||||
editModeReview: string;
|
||||
rejectedEdit: string;
|
||||
autoApprovingRest: string;
|
||||
flippedAutoSession: string;
|
||||
flippedAutoWalk: string;
|
||||
dashboardStopped: string;
|
||||
notedMemory: string;
|
||||
notedScopeProject: string;
|
||||
notedScopeGlobal: string;
|
||||
notedVerbCreated: string;
|
||||
notedVerbAppended: string;
|
||||
memoryWriteFailed: string;
|
||||
commandFailed: string;
|
||||
restoreCodeOnly: string;
|
||||
hookUserPromptSubmit: string;
|
||||
hookStop: string;
|
||||
atMentions: string;
|
||||
atUrl: string;
|
||||
atUrlFailed: string;
|
||||
denied: string;
|
||||
alwaysAllowed: string;
|
||||
runningCommand: string;
|
||||
startingBackground: string;
|
||||
checkpointSaved: string;
|
||||
continuingAfter: string;
|
||||
planStoppedAt: string;
|
||||
revisingAfter: string;
|
||||
};
|
||||
hooks: {
|
||||
head: string;
|
||||
headWithDetail: string;
|
||||
truncated: string;
|
||||
decisionBlock: string;
|
||||
decisionWarn: string;
|
||||
decisionTimeout: string;
|
||||
decisionError: string;
|
||||
};
|
||||
summary: {
|
||||
status: string;
|
||||
hallucinatedFallback: string;
|
||||
failedAfterReason: string;
|
||||
};
|
||||
loop: {
|
||||
budgetWarning: string;
|
||||
budgetRefusal: string;
|
||||
contextWarning: string;
|
||||
escalationWarning: string;
|
||||
toolFailure: string;
|
||||
interrupted: string;
|
||||
loopStopped: string;
|
||||
budgetExhausted: string;
|
||||
budget80Pct: string;
|
||||
proArmed: string;
|
||||
abortedAtIter: string;
|
||||
toolUploadStatus: string;
|
||||
toolBudgetWarning: string;
|
||||
preflightFoldStatus: string;
|
||||
preflightFolded: string;
|
||||
preflightNoFold: string;
|
||||
flashEscalation: string;
|
||||
harvestStatus: string;
|
||||
autoEscalation: string;
|
||||
repeatToolCallWarning: string;
|
||||
stormStuck: string;
|
||||
stormSuppressed: string;
|
||||
compactingHistoryStatus: string;
|
||||
aggressiveTag: string;
|
||||
foldedHistory: string;
|
||||
aggressivelyFoldedHistory: string;
|
||||
forcingSummary: string;
|
||||
};
|
||||
errors: {
|
||||
contextOverflow: string;
|
||||
contextOverflowTooMany: string;
|
||||
auth401: string;
|
||||
balance402: string;
|
||||
badparam422: string;
|
||||
badrequest400: string;
|
||||
deepseek5xxHead: string;
|
||||
deepseek5xxReachable: string;
|
||||
deepseek5xxUnreachable: string;
|
||||
deepseek5xxActionNetwork: string;
|
||||
deepseek5xxActionRetry: string;
|
||||
innerNoMessage: string;
|
||||
reasonAborted: string;
|
||||
reasonContextGuard: string;
|
||||
reasonStuck: string;
|
||||
reasonBudget: string;
|
||||
labelAborted: string;
|
||||
labelContextGuard: string;
|
||||
labelStuck: string;
|
||||
labelBudget: string;
|
||||
};
|
||||
handlers: {
|
||||
[group: string]: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
wizard: {
|
||||
languageTitle: string;
|
||||
languageSubtitle: string;
|
||||
welcomeTitle: string;
|
||||
apiKeyPrompt: string;
|
||||
apiKeyGetOne: string;
|
||||
apiKeySavedLocally: string;
|
||||
apiKeyInputLabel: string;
|
||||
apiKeyInvalid: string;
|
||||
apiKeyPreview: string;
|
||||
presetTitle: string;
|
||||
mcpTitle: string;
|
||||
mcpUserArgsHint: string;
|
||||
mcpFooterMulti: string;
|
||||
mcpArgsTitle: string;
|
||||
mcpArgsDirMissing: string;
|
||||
mcpArgsDirCreateHint: string;
|
||||
mcpArgsDirCreateFailed: string;
|
||||
mcpArgsRequiredParam: string;
|
||||
mcpArgsEmpty: string;
|
||||
mcpArgsNotADir: string;
|
||||
reviewTitle: string;
|
||||
reviewLabelApiKey: string;
|
||||
reviewLabelLanguage: string;
|
||||
reviewLabelPreset: string;
|
||||
reviewLabelMcp: string;
|
||||
reviewMcpNone: string;
|
||||
reviewMcpServers: string;
|
||||
reviewSavesTo: string;
|
||||
reviewSaveError: string;
|
||||
reviewFooter: string;
|
||||
savedTitle: string;
|
||||
savedFooter: string;
|
||||
selectFooter: string;
|
||||
stepCounter: string;
|
||||
};
|
||||
}
|
||||
+174
-7
@@ -278,14 +278,153 @@ export const zhCN: TranslationSchema = {
|
||||
argsHint: "<id> [lines]",
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
languageTitle: "选择语言",
|
||||
languageSubtitle: "已根据系统语言自动选中。之后可用 /language 切换。",
|
||||
welcomeTitle: "欢迎使用 Reasonix。",
|
||||
apiKeyPrompt: "粘贴你的 DeepSeek API key 开始使用。",
|
||||
apiKeyGetOne: "在此获取:https://platform.deepseek.com/api_keys",
|
||||
apiKeySavedLocally: "保存在本地:{path}",
|
||||
apiKeyInputLabel: "key › ",
|
||||
apiKeyInvalid: "这看起来不像 DeepSeek 的 key。它们以 'sk-' 开头,30 字符以上。",
|
||||
apiKeyPreview: "预览:{redacted}",
|
||||
presetTitle: "选择预设",
|
||||
mcpTitle: "Reasonix 要为你接入哪些 MCP 服务器?",
|
||||
mcpUserArgsHint: "(需要你提供 {arg})",
|
||||
mcpFooterMulti: "[↑↓] 移动 · [空格] 选择 · [Enter] 确认 · [Esc] 取消 · 全不选 = 跳过",
|
||||
mcpArgsTitle: "配置 {name}",
|
||||
mcpArgsDirMissing: "目录 {path} 不存在。",
|
||||
mcpArgsDirCreateHint: "[Y/Enter] 创建(mkdir -p)· [N/Esc] 输入其他路径",
|
||||
mcpArgsDirCreateFailed: "无法创建 {path}:{message}",
|
||||
mcpArgsRequiredParam: "必填参数:",
|
||||
mcpArgsEmpty: "{name} 需要一个值 — 不能为空。",
|
||||
mcpArgsNotADir: "{path} 存在但不是目录。",
|
||||
reviewTitle: "确认保存",
|
||||
reviewLabelApiKey: "API key",
|
||||
reviewLabelLanguage: "语言",
|
||||
reviewLabelPreset: "预设",
|
||||
reviewLabelMcp: "MCP",
|
||||
reviewMcpNone: "(无)",
|
||||
reviewMcpServers: "{count} 个服务器",
|
||||
reviewSavesTo: "保存到 {path}",
|
||||
reviewSaveError: "保存配置失败:{message}",
|
||||
reviewFooter: "[Enter] 保存 · [Esc] 取消",
|
||||
savedTitle: "▸ 已保存。",
|
||||
savedFooter: "[Enter] 退出",
|
||||
selectFooter: "[↑↓] 移动 · [Enter] 确认 · [Esc] 取消",
|
||||
stepCounter: "步骤 {step}/{total} · ",
|
||||
},
|
||||
app: {
|
||||
walkCancelledRemaining: "▸ 浏览已取消 — 还有 {count} 个待处理编辑块。",
|
||||
walkCancelled: "▸ 浏览已取消。",
|
||||
editModeYolo:
|
||||
"▸ 编辑模式:YOLO — 编辑和 shell 命令都自动执行。/undo 仍可撤销编辑。请谨慎使用。",
|
||||
editModeAuto:
|
||||
"▸ 编辑模式:AUTO — 编辑立即应用;5 秒内按 u 撤销(空格暂停计时)。shell 命令仍会询问。",
|
||||
editModeReview: "▸ 编辑模式:review — 编辑入队待 /apply(或 y)/ /discard(或 n)",
|
||||
rejectedEdit: "▸ 拒绝了对 {path} 的编辑{context}",
|
||||
autoApprovingRest: "▸ 本轮剩余编辑自动批准",
|
||||
flippedAutoSession: "▸ 已切换到 AUTO 模式(本会话剩余生效,已持久化)",
|
||||
flippedAutoWalk: "▸ 已切换到 AUTO 模式 — 后续编辑立即应用。浏览模式退出。",
|
||||
dashboardStopped: "▸ 仪表板已停止。",
|
||||
notedMemory: "▸ 已记录({scope})— {verb} {path}",
|
||||
notedScopeProject: "项目",
|
||||
notedScopeGlobal: "全局",
|
||||
notedVerbCreated: "创建",
|
||||
notedVerbAppended: "追加到",
|
||||
memoryWriteFailed: "# 记忆写入失败",
|
||||
commandFailed: "! 命令失败",
|
||||
restoreCodeOnly: "▸ /restore 仅在代码模式可用",
|
||||
hookUserPromptSubmit: "UserPromptSubmit 钩子",
|
||||
hookStop: "Stop 钩子",
|
||||
atMentions: "▸ @mentions:{parts}",
|
||||
atUrl: "▸ @url:{parts}",
|
||||
atUrlFailed: "@url 展开失败",
|
||||
denied: "▸ 已拒绝:{cmd}{context}",
|
||||
alwaysAllowed: '▸ 已对 {dir} 永久允许 "{prefix}"',
|
||||
runningCommand: "▸ 正在执行:{cmd}",
|
||||
startingBackground: "▸ 后台启动:{cmd}",
|
||||
checkpointSaved: "⛁ 已保存检查点 · {id} · {count} 个文件 · /restore {id} 可回滚此步",
|
||||
continuingAfter: "▸ 在 {label}{counter} 之后继续",
|
||||
planStoppedAt: "▸ 计划在 {label}{counter} 处停止",
|
||||
revisingAfter: "▸ 在 {label} 之后修订 — {feedback}",
|
||||
},
|
||||
hooks: {
|
||||
head: "钩子 {tag} `{cmd}` {decision}{truncTag}",
|
||||
headWithDetail: "钩子 {tag} `{cmd}` {decision}{truncTag}:{detail}",
|
||||
truncated: "(输出在 256KB 处截断)",
|
||||
decisionBlock: "拦截",
|
||||
decisionWarn: "告警",
|
||||
decisionTimeout: "超时",
|
||||
decisionError: "错误",
|
||||
},
|
||||
summary: {
|
||||
status: "正在总结已收集的内容…",
|
||||
hallucinatedFallback:
|
||||
"(模型生成了伪造的工具调用标记而非纯文本总结 — 试试 /retry 换个更窄的问题,或 /think 查看 R1 的推理)",
|
||||
failedAfterReason:
|
||||
"{label},且回退的总结调用也失败:{message}。请运行 /clear 后用更窄的问题重试,或提高 --max-tool-iters。",
|
||||
},
|
||||
loop: {
|
||||
budgetWarning: "▲ 会话预算达到 80% ($${used} / $${cap})",
|
||||
budgetRefusal: "▲ 会话预算达到 100% ($${used} / $${cap}) — 拒绝执行。通过 /budget 提高或清除。",
|
||||
contextWarning: "▲ 上下文窗口压力:{ratio}% ({tokens} / {cap})",
|
||||
escalationWarning: "⇧ flash 请求升级 — {reason}",
|
||||
toolFailure: "▸ 工具 {name} 失败:{reason}",
|
||||
interrupted: "▸ 用户中断了执行。",
|
||||
loopStopped: "▸ 循环停止(在执行 {count} 次之后)。",
|
||||
budgetExhausted:
|
||||
"会话预算已用完 — 已花费 ${spent} ≥ 上限 ${cap}。用 /budget <usd> 提高上限,/budget off 清除上限,或结束会话。",
|
||||
budget80Pct: "▲ 预算已用 80% — ${spent} / ${cap}。下一两轮可能就触顶。",
|
||||
proArmed: "⇧ /pro 已装备 — 本轮使用 deepseek-v4-pro(一次性 · 本轮后自动解除)",
|
||||
abortedAtIter:
|
||||
"在第 {iter}/{cap} 次工具调用处中断 — 未生成总结即停止(按 ↑ + Enter 或 /retry 恢复)",
|
||||
toolUploadStatus: "工具结果已上传 · 模型在生成下一条响应前思考中…",
|
||||
toolBudgetWarning: "已用 {iter}/{cap} 次工具调用 — 接近上限。按 Esc 立即强制总结。",
|
||||
preflightFoldStatus: "预检:上下文接近上限,尝试折叠…",
|
||||
preflightFolded:
|
||||
"预检:请求约 {estimate}/{ctxMax} tokens({pct}%)— 已折叠 {beforeMessages} 条消息 → {afterMessages}(总结 {summaryChars} 字)。发送中。",
|
||||
preflightNoFold:
|
||||
"预检:请求约 {estimate}/{ctxMax} tokens({pct}%)且没有可折叠的内容 — DeepSeek 大概率会返回 400。请运行 /clear 或 /new 重新开始。",
|
||||
flashEscalation: "⇧ flash 请求升级 — 本轮改用 {model}{reasonSuffix}",
|
||||
harvestStatus: "正在从推理过程提取计划状态…",
|
||||
autoEscalation:
|
||||
"⇧ 本轮剩余调用自动升级到 {model} — flash 命中 {breakdown}。下一轮回退到 {fallback},除非已装备 /pro。",
|
||||
repeatToolCallWarning: "拦截到重复工具调用 — 让模型察觉问题并换种方式重试。",
|
||||
stormStuck:
|
||||
"已停止卡死的重试循环 — 模型在自纠提示后仍以相同参数重复调用同一工具。请尝试 /retry、换种说法,或排查底层阻塞。",
|
||||
stormSuppressed: "已抑制 {count} 次重复工具调用 — 同一名称 + 参数触发 3 次以上。",
|
||||
compactingHistoryStatus: "正在压缩历史{aggressiveTag}…",
|
||||
aggressiveTag: "(激进)",
|
||||
foldedHistory:
|
||||
"上下文 {before}/{ctxMax}({pct}%)— 已折叠 {beforeMessages} 条消息 → {afterMessages}(总结 {summaryChars} 字)。继续。",
|
||||
aggressivelyFoldedHistory:
|
||||
"上下文 {before}/{ctxMax}({pct}%)— 已激进折叠 {beforeMessages} 条消息 → {afterMessages}(总结 {summaryChars} 字)。继续。",
|
||||
forcingSummary:
|
||||
"上下文 {before}/{ctxMax}({pct}%)— 基于已收集到的内容强制总结。请运行 /compact、/clear 或 /new 重置。",
|
||||
},
|
||||
errors: {
|
||||
contextOverflow:
|
||||
"上下文溢出(DeepSeek 400):会话历史已达 {requested},超出模型 prompt 上限(V4:1M tokens;旧版 chat/reasoner:131k)。通常是单个工具结果太大。Reasonix 默认将新工具结果限制在 8k tokens,并在会话加载时自动修复超大历史 — 重启常能清掉。如果仍然溢出,运行 /forget(删除会话)或 /clear(丢弃显示中的历史)从头开始。",
|
||||
contextOverflowTooMany: "tokens 数量过多",
|
||||
auth401:
|
||||
"认证失败(DeepSeek 401):{inner}。你的 API key 被拒绝。运行 `reasonix setup` 或 `export DEEPSEEK_API_KEY=sk-...` 修复。在 https://platform.deepseek.com/api_keys 获取 key。",
|
||||
balance402:
|
||||
"余额不足(DeepSeek 402):{inner}。在 https://platform.deepseek.com/top_up 充值 — 余额非零时面板顶栏会显示。",
|
||||
badparam422: "参数错误(DeepSeek 422):{inner}",
|
||||
badrequest400: "请求错误(DeepSeek 400):{inner}",
|
||||
deepseek5xxHead:
|
||||
"DeepSeek 服务不可用({status}) — 这是 DeepSeek 服务端问题,不是 Reasonix 故障。已按指数退避重试 4 次。",
|
||||
deepseek5xxReachable:
|
||||
" DeepSeek 主 API 健康检查通过,但 /chat/completions 在挂 — 他们那边部分服务异常。",
|
||||
deepseek5xxUnreachable:
|
||||
" 无法从你的网络访问 DeepSeek API — 可能是 DS 整体故障,也可能是本地网络问题。",
|
||||
deepseek5xxActionNetwork:
|
||||
" 建议:(1) 检查网络,(2) 等 30 秒后重试,(3) 查看状态页 https://status.deepseek.com。",
|
||||
deepseek5xxActionRetry:
|
||||
" 建议:(1) 等 30 秒后重试,(2) 用 /preset 切换模型,(3) 查看状态页 https://status.deepseek.com。",
|
||||
innerNoMessage: "(无错误信息)",
|
||||
reasonAborted: "[用户已中断(Esc) — 正在总结到目前为止的发现]",
|
||||
reasonContextGuard: "[上下文额度即将耗尽 — 在下一次调用溢出之前先总结]",
|
||||
reasonStuck: "[卡在重复的工具调用上 — 说明已尝试的方法以及阻塞点]",
|
||||
reasonBudget: "[工具调用配额({iterCap})已用尽 — 基于已发现的内容强制总结]",
|
||||
labelAborted: "用户中断",
|
||||
labelContextGuard: "触发上下文保护(prompt > 80% 窗口)",
|
||||
labelStuck: "卡死(重复工具调用被反风暴机制抑制)",
|
||||
labelBudget: "工具调用配额({iterCap})已用尽",
|
||||
},
|
||||
handlers: {
|
||||
basic: {
|
||||
@@ -675,6 +814,9 @@ export const zhCN: TranslationSchema = {
|
||||
"此会话尚无归档计划 — `/replay` 在计划完成后启用(每个步骤完成时自动归档)。",
|
||||
replayInvalidIndex:
|
||||
"无效索引 — `/replay` 接受 1..{max}(最新 = 1)。使用 `/plans` 查看列表。",
|
||||
archivedRow: " ✓ {when} {total}步 · {completion} {label}",
|
||||
completionComplete: "已完成",
|
||||
stopAborted: "▸ 计划已停止 — 模型已中止;输入后续内容继续,或开始新任务。",
|
||||
},
|
||||
jobs: {
|
||||
codeOnly: "/jobs 仅在 `reasonix code` 中可用。",
|
||||
@@ -743,6 +885,12 @@ export const zhCN: TranslationSchema = {
|
||||
fallbackServers: "MCP 服务器({count}):",
|
||||
fallbackTools: "注册表中的工具({count}):",
|
||||
fallbackChange: "要更改此设置,请退出并运行 `reasonix setup`。",
|
||||
usageDisableEnable:
|
||||
"用法:/mcp {action} <name> · 从 /mcp 列表中挑一个名字(匿名服务器无法按名切换)。",
|
||||
usageReconnect: "用法:/mcp reconnect <name> · 从 /mcp 列表中挑一个名字。",
|
||||
unknownServer: '未知 MCP 服务器 "{name}"。已知:{list}。',
|
||||
noneList: "(无)",
|
||||
reconnectNoTui: "/mcp reconnect 需要交互式 TUI(postInfo 未连接)。",
|
||||
},
|
||||
init: {
|
||||
codeOnly:
|
||||
@@ -753,6 +901,25 @@ export const zhCN: TranslationSchema = {
|
||||
existsPinned: " 固定到每次启动的系统提示词中。",
|
||||
info: "▸ /init — 模型将扫描项目并合成 REASONIX.md。\n 结果将作为待处理的编辑;使用 /apply 或 /walk 审查。",
|
||||
},
|
||||
semantic: {
|
||||
codeOnly: "/semantic 仅在 `reasonix code` 中可用(需要项目根目录)。",
|
||||
checking: "▸ 正在检查 semantic_search 状态…",
|
||||
},
|
||||
webSearchEngine: {
|
||||
currentEngine: "当前网页搜索引擎:{engine}",
|
||||
endpoint: "SearXNG 端点:{url}",
|
||||
usageHeader: "用法:",
|
||||
usageMojeek: " /search-engine mojeek 使用 Mojeek(默认,无外部依赖)",
|
||||
usageSearxng: " /search-engine searxng 使用 SearXNG 默认端点",
|
||||
usageSearxngUrl: " /search-engine searxng <url> 使用 SearXNG 自定义端点",
|
||||
alias: "别名:/se",
|
||||
searxngInfo: "SearXNG 是一个自托管的元搜索引擎(https://github.com/searxng/searxng)。",
|
||||
searxngInstall: "安装命令: docker run -d -p 8080:8080 searxng/searxng",
|
||||
switched: '已切换网页搜索引擎为 "{engine}"。{note}',
|
||||
switchedSearxngNote: " 请确保 SearXNG 在 {endpoint} 运行。",
|
||||
confirmed: '✓ 网页搜索引擎已设为 "{engine}"{detail}。下一轮模型调用将生效。',
|
||||
confirmedDetail: "({endpoint})",
|
||||
},
|
||||
skill: {
|
||||
listEmpty: "未找到技能。Reasonix 从以下位置读取技能:",
|
||||
listProjectScope:
|
||||
|
||||
+68
-31
@@ -17,8 +17,9 @@ import {
|
||||
} from "./mcp/registry.js";
|
||||
|
||||
import { ContextManager } from "./context-manager.js";
|
||||
import { t } from "./i18n/index.js";
|
||||
import { summarizeBranch } from "./loop/branch.js";
|
||||
import { formatLoopError } from "./loop/errors.js";
|
||||
import { formatLoopError, is5xxError, probeDeepSeekReachable } from "./loop/errors.js";
|
||||
import {
|
||||
NEEDS_PRO_BUFFER_CHARS,
|
||||
isEscalationRequest,
|
||||
@@ -397,6 +398,11 @@ export class CacheFirstLoop {
|
||||
return this._escalateThisTurn;
|
||||
}
|
||||
|
||||
/** UI surface — model id of the call about to run (or running) right now, including escalation. */
|
||||
get currentCallModel(): string {
|
||||
return this.modelForCurrentCall();
|
||||
}
|
||||
|
||||
private modelForCurrentCall(): string {
|
||||
return this._escalateThisTurn ? ESCALATION_MODEL : this.model;
|
||||
}
|
||||
@@ -508,7 +514,10 @@ export class CacheFirstLoop {
|
||||
turn: this._turn,
|
||||
role: "error",
|
||||
content: "",
|
||||
error: `session budget exhausted — spent $${spent.toFixed(4)} ≥ cap $${this.budgetUsd.toFixed(2)}. Bump the cap with /budget <usd>, clear it with /budget off, or end the session.`,
|
||||
error: t("loop.budgetExhausted", {
|
||||
spent: spent.toFixed(4),
|
||||
cap: this.budgetUsd.toFixed(2),
|
||||
}),
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -517,7 +526,10 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `▲ budget 80% used — $${spent.toFixed(4)} of $${this.budgetUsd.toFixed(2)}. Next turn or two likely trips the cap.`,
|
||||
content: t("loop.budget80Pct", {
|
||||
spent: spent.toFixed(4),
|
||||
cap: this.budgetUsd.toFixed(2),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -562,7 +574,7 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: "⇧ /pro armed — this turn runs on deepseek-v4-pro (one-shot · disarms after turn)",
|
||||
content: t("loop.proArmed"),
|
||||
};
|
||||
}
|
||||
let pendingUser: string | null = userInput;
|
||||
@@ -591,7 +603,7 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `aborted at iter ${iter}/${this.maxToolIters} — stopped without producing a summary (press ↑ + Enter or /retry to resume)`,
|
||||
content: t("loop.abortedAtIter", { iter, cap: this.maxToolIters }),
|
||||
};
|
||||
const stoppedMsg =
|
||||
"[aborted by user (Esc) — no summary produced. Ask again or /retry when ready; prior tool output is still in the log.]";
|
||||
@@ -637,7 +649,7 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "status",
|
||||
content: "tool result uploaded · model thinking before next response…",
|
||||
content: t("loop.toolUploadStatus"),
|
||||
};
|
||||
}
|
||||
if (!warnedForIterBudget && iter >= warnAt) {
|
||||
@@ -645,7 +657,7 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `${iter}/${this.maxToolIters} tool calls used — approaching budget. Press Esc to force a summary now.`,
|
||||
content: t("loop.toolBudgetWarning", { iter, cap: this.maxToolIters }),
|
||||
};
|
||||
}
|
||||
let messages = this.buildMessages(pendingUser);
|
||||
@@ -663,16 +675,21 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "status",
|
||||
content: "preflight: context near full, attempting fold…",
|
||||
content: t("loop.preflightFoldStatus"),
|
||||
};
|
||||
const result = await this.context.fold(this.model);
|
||||
if (result.folded) {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax.toLocaleString()} tokens (${Math.round(
|
||||
(estimate / ctxMax) * 100,
|
||||
)}%) — folded ${result.beforeMessages} messages → ${result.afterMessages} (summary ${result.summaryChars} chars). Sending.`,
|
||||
content: t("loop.preflightFolded", {
|
||||
estimate: estimate.toLocaleString(),
|
||||
ctxMax: ctxMax.toLocaleString(),
|
||||
pct: Math.round((estimate / ctxMax) * 100),
|
||||
beforeMessages: result.beforeMessages,
|
||||
afterMessages: result.afterMessages,
|
||||
summaryChars: result.summaryChars,
|
||||
}),
|
||||
};
|
||||
// Rebuild with the folded log so we send the smaller payload.
|
||||
messages = this.buildMessages(pendingUser);
|
||||
@@ -680,9 +697,11 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax.toLocaleString()} tokens (${Math.round(
|
||||
(estimate / ctxMax) * 100,
|
||||
)}%) and nothing left to fold — DeepSeek will likely 400. Run /clear or /new to start fresh.`,
|
||||
content: t("loop.preflightNoFold", {
|
||||
estimate: estimate.toLocaleString(),
|
||||
ctxMax: ctxMax.toLocaleString(),
|
||||
pct: Math.round((estimate / ctxMax) * 100),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -948,11 +967,12 @@ export class CacheFirstLoop {
|
||||
this._turnAbort = new AbortController();
|
||||
return;
|
||||
}
|
||||
const probe = is5xxError(err) ? await probeDeepSeekReachable(this.client) : undefined;
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "error",
|
||||
content: "",
|
||||
error: formatLoopError(err as Error),
|
||||
error: formatLoopError(err as Error, probe),
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -977,7 +997,7 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `⇧ flash requested escalation — retrying this turn on ${ESCALATION_MODEL}${reasonSuffix}`,
|
||||
content: t("loop.flashEscalation", { model: ESCALATION_MODEL, reasonSuffix }),
|
||||
};
|
||||
// Reset per-iter state. We don't record stats for the rejected
|
||||
// flash call (cost is small — a ~20-token lead-in that we broke
|
||||
@@ -1022,7 +1042,7 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "status",
|
||||
content: "extracting plan state from reasoning…",
|
||||
content: t("loop.harvestStatus"),
|
||||
};
|
||||
}
|
||||
const planState = preHarvestedPlanState
|
||||
@@ -1064,7 +1084,11 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `⇧ auto-escalating to ${ESCALATION_MODEL} for the rest of this turn — flash hit ${this._turnFailures.formatBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`,
|
||||
content: t("loop.autoEscalation", {
|
||||
model: ESCALATION_MODEL,
|
||||
breakdown: this._turnFailures.formatBreakdown(),
|
||||
fallback: this.model,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1097,8 +1121,7 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content:
|
||||
"Caught a repeated tool call — let the model see the issue and retry with a different approach.",
|
||||
content: t("loop.repeatToolCallWarning"),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
@@ -1106,8 +1129,8 @@ export class CacheFirstLoop {
|
||||
if (report.stormsBroken > 0) {
|
||||
const noteTail = report.notes.length ? ` — ${report.notes[report.notes.length - 1]}` : "";
|
||||
const phrase = allSuppressed
|
||||
? "Stopped a stuck retry loop — the model kept calling the same tool with identical args after a self-correction nudge. Try /retry, rephrase, or rule out the underlying blocker."
|
||||
: `Suppressed ${report.stormsBroken} repeated tool call(s) — same name + args fired 3+ times.`;
|
||||
? t("loop.stormStuck")
|
||||
: t("loop.stormSuppressed", { count: report.stormsBroken });
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
@@ -1131,20 +1154,28 @@ export class CacheFirstLoop {
|
||||
this._foldedThisTurn = true;
|
||||
const before = decision.promptTokens;
|
||||
const ctxMax = decision.ctxMax;
|
||||
const aggressiveTag = decision.aggressive ? " (aggressive)" : "";
|
||||
const aggressiveTag = decision.aggressive ? t("loop.aggressiveTag") : "";
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "status",
|
||||
content: `compacting history${aggressiveTag}…`,
|
||||
content: t("loop.compactingHistoryStatus", { aggressiveTag }),
|
||||
};
|
||||
const result = await this.compactHistory({ keepRecentTokens: decision.tailBudget });
|
||||
if (result.folded) {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
|
||||
(before / ctxMax) * 100,
|
||||
)}%) — ${decision.aggressive ? "aggressively folded" : "folded"} ${result.beforeMessages} messages → ${result.afterMessages} (summary ${result.summaryChars} chars). Continuing.`,
|
||||
content: t(
|
||||
decision.aggressive ? "loop.aggressivelyFoldedHistory" : "loop.foldedHistory",
|
||||
{
|
||||
before: before.toLocaleString(),
|
||||
ctxMax: ctxMax.toLocaleString(),
|
||||
pct: Math.round((before / ctxMax) * 100),
|
||||
beforeMessages: result.beforeMessages,
|
||||
afterMessages: result.afterMessages,
|
||||
summaryChars: result.summaryChars,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
} else if (decision.kind === "exit-with-summary") {
|
||||
@@ -1153,9 +1184,11 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
|
||||
(before / ctxMax) * 100,
|
||||
)}%) — forcing summary from what was gathered. Run /compact, /clear, or /new to reset.`,
|
||||
content: t("loop.forcingSummary", {
|
||||
before: before.toLocaleString(),
|
||||
ctxMax: ctxMax.toLocaleString(),
|
||||
pct: Math.round((before / ctxMax) * 100),
|
||||
}),
|
||||
};
|
||||
this.context.trimTrailingToolCalls();
|
||||
yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "context-guard" });
|
||||
@@ -1239,7 +1272,11 @@ export class CacheFirstLoop {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `⇧ auto-escalating to ${ESCALATION_MODEL} for the rest of this turn — flash hit ${this._turnFailures.formatBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`,
|
||||
content: t("loop.autoEscalation", {
|
||||
model: ESCALATION_MODEL,
|
||||
breakdown: this._turnFailures.formatBreakdown(),
|
||||
fallback: this.model,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+57
-29
@@ -1,12 +1,18 @@
|
||||
/** Single text-layer DeepSeek-error formatter — 429/5xx never reach here (retry.ts swallows). */
|
||||
export function formatLoopError(err: Error): string {
|
||||
import type { DeepSeekClient } from "../client.js";
|
||||
import { t } from "../i18n/index.js";
|
||||
|
||||
export interface DeepSeekProbeResult {
|
||||
reachable: boolean;
|
||||
}
|
||||
|
||||
export function formatLoopError(err: Error, probe?: DeepSeekProbeResult): string {
|
||||
const msg = err.message ?? "";
|
||||
if (msg.includes("maximum context length")) {
|
||||
const reqMatch = msg.match(/requested\s+(\d+)\s+tokens/);
|
||||
const requested = reqMatch
|
||||
? `${Number(reqMatch[1]).toLocaleString()} tokens`
|
||||
: "too many tokens";
|
||||
return `Context overflow (DeepSeek 400): session history is ${requested}, past the model's prompt limit (V4: 1M tokens; legacy chat/reasoner: 131k). Usually a single tool result grew too big. Reasonix caps new tool results at 8k tokens and auto-heals oversized history on session load — a restart often clears it. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
|
||||
: t("errors.contextOverflowTooMany");
|
||||
return t("errors.contextOverflow", { requested });
|
||||
}
|
||||
|
||||
const m = /^DeepSeek (\d{3}):\s*([\s\S]*)$/.exec(msg);
|
||||
@@ -15,48 +21,70 @@ export function formatLoopError(err: Error): string {
|
||||
const body = m[2] ?? "";
|
||||
const inner = extractDeepSeekErrorMessage(body);
|
||||
|
||||
if (status === "401") {
|
||||
return `Authentication failed (DeepSeek 401): ${inner}. Your API key is rejected. Fix with \`reasonix setup\` or \`export DEEPSEEK_API_KEY=sk-...\`. Get one at https://platform.deepseek.com/api_keys.`;
|
||||
}
|
||||
if (status === "402") {
|
||||
return `Out of balance (DeepSeek 402): ${inner}. Top up at https://platform.deepseek.com/top_up — the panel header shows your balance once it's non-zero.`;
|
||||
}
|
||||
if (status === "422") {
|
||||
return `Invalid parameter (DeepSeek 422): ${inner}`;
|
||||
}
|
||||
if (status === "400") {
|
||||
return `Bad request (DeepSeek 400): ${inner}`;
|
||||
}
|
||||
if (status === "401") return t("errors.auth401", { inner });
|
||||
if (status === "402") return t("errors.balance402", { inner });
|
||||
if (status === "422") return t("errors.badparam422", { inner });
|
||||
if (status === "400") return t("errors.badrequest400", { inner });
|
||||
if (is5xxStatus(status)) return formatDeepSeek5xx(status, probe);
|
||||
return msg;
|
||||
}
|
||||
|
||||
export function is5xxError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const m = /^DeepSeek (5\d{2}):/.exec(err.message ?? "");
|
||||
return m !== null;
|
||||
}
|
||||
|
||||
export async function probeDeepSeekReachable(
|
||||
client: DeepSeekClient,
|
||||
timeoutMs = 1500,
|
||||
): Promise<DeepSeekProbeResult> {
|
||||
const balance = await client.getBalance({ signal: AbortSignal.timeout(timeoutMs) });
|
||||
return { reachable: balance !== null };
|
||||
}
|
||||
|
||||
function is5xxStatus(status: string): boolean {
|
||||
return status === "500" || status === "502" || status === "503" || status === "504";
|
||||
}
|
||||
|
||||
function formatDeepSeek5xx(status: string, probe?: DeepSeekProbeResult): string {
|
||||
const head = t("errors.deepseek5xxHead", { status });
|
||||
const probeNote =
|
||||
probe === undefined
|
||||
? ""
|
||||
: probe.reachable
|
||||
? t("errors.deepseek5xxReachable")
|
||||
: t("errors.deepseek5xxUnreachable");
|
||||
const action =
|
||||
probe?.reachable === false
|
||||
? t("errors.deepseek5xxActionNetwork")
|
||||
: t("errors.deepseek5xxActionRetry");
|
||||
return `${head}${probeNote}${action}`;
|
||||
}
|
||||
|
||||
export function reasonPrefixFor(
|
||||
reason: "budget" | "aborted" | "context-guard" | "stuck",
|
||||
iterCap: number,
|
||||
): string {
|
||||
if (reason === "aborted") return "[aborted by user (Esc) — summarizing what I found so far]";
|
||||
if (reason === "context-guard") {
|
||||
return "[context budget running low — summarizing before the next call would overflow]";
|
||||
}
|
||||
if (reason === "stuck") {
|
||||
return "[stuck on a repeated tool call — explaining what was tried and what's blocking progress]";
|
||||
}
|
||||
return `[tool-call budget (${iterCap}) reached — forcing summary from what I found]`;
|
||||
if (reason === "aborted") return t("errors.reasonAborted");
|
||||
if (reason === "context-guard") return t("errors.reasonContextGuard");
|
||||
if (reason === "stuck") return t("errors.reasonStuck");
|
||||
return t("errors.reasonBudget", { iterCap });
|
||||
}
|
||||
|
||||
export function errorLabelFor(
|
||||
reason: "budget" | "aborted" | "context-guard" | "stuck",
|
||||
iterCap: number,
|
||||
): string {
|
||||
if (reason === "aborted") return "aborted by user";
|
||||
if (reason === "context-guard") return "context-guard triggered (prompt > 80% of window)";
|
||||
if (reason === "stuck") return "stuck (repeated tool call suppressed by storm-breaker)";
|
||||
return `tool-call budget (${iterCap}) reached`;
|
||||
if (reason === "aborted") return t("errors.labelAborted");
|
||||
if (reason === "context-guard") return t("errors.labelContextGuard");
|
||||
if (reason === "stuck") return t("errors.labelStuck");
|
||||
return t("errors.labelBudget", { iterCap });
|
||||
}
|
||||
|
||||
function extractDeepSeekErrorMessage(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return "(no message)";
|
||||
if (!trimmed) return t("errors.innerNoMessage");
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type DeepSeekClient, Usage } from "../client.js";
|
||||
import { t } from "../i18n/index.js";
|
||||
import type { TurnStats } from "../telemetry/stats.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
import { errorLabelFor, reasonPrefixFor } from "./errors.js";
|
||||
@@ -24,7 +25,7 @@ export async function* forceSummaryAfterIterLimit(
|
||||
): AsyncGenerator<LoopEvent> {
|
||||
try {
|
||||
// Status bridges the silence — summary call is non-streaming, 30-60s typical.
|
||||
yield { turn: ctx.turn, role: "status", content: "summarizing what was gathered…" };
|
||||
yield { turn: ctx.turn, role: "status", content: t("summary.status") };
|
||||
const messages = ctx.buildMessages();
|
||||
// Passing `tools: undefined` was supposed to force a text response,
|
||||
// but R1 can still hallucinate tool-call markup (e.g. DSML
|
||||
@@ -50,9 +51,7 @@ export async function* forceSummaryAfterIterLimit(
|
||||
});
|
||||
const rawContent = resp.content?.trim() ?? "";
|
||||
const cleaned = stripHallucinatedToolMarkup(rawContent);
|
||||
const summary =
|
||||
cleaned ||
|
||||
"(model emitted fake tool-call markup instead of a prose summary — try /retry with a narrower question, or /think to inspect R1's reasoning)";
|
||||
const summary = cleaned || t("summary.hallucinatedFallback");
|
||||
const reasonPrefix = reasonPrefixFor(opts.reason, ctx.maxToolIters);
|
||||
const annotated = `${reasonPrefix}\n\n${summary}`;
|
||||
// Record under the actual model used (flash), so per-turn cost reflects reality.
|
||||
@@ -72,7 +71,7 @@ export async function* forceSummaryAfterIterLimit(
|
||||
turn: ctx.turn,
|
||||
role: "error",
|
||||
content: "",
|
||||
error: `${label} and the fallback summary call failed: ${(err as Error).message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.`,
|
||||
error: t("summary.failedAfterReason", { label, message: (err as Error).message }),
|
||||
};
|
||||
yield { turn: ctx.turn, role: "done", content: "" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { DashboardContext } from "../context.js";
|
||||
import type { ApiResult } from "../router.js";
|
||||
|
||||
interface LoopStartBody {
|
||||
intervalMs?: unknown;
|
||||
prompt?: unknown;
|
||||
}
|
||||
|
||||
function parseBody(raw: string): LoopStartBody {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === "object" && parsed !== null ? (parsed as LoopStartBody) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_INTERVAL_MS = 5_000;
|
||||
const MAX_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export async function handleLoop(
|
||||
method: string,
|
||||
rest: string[],
|
||||
body: string,
|
||||
ctx: DashboardContext,
|
||||
): Promise<ApiResult> {
|
||||
if (method === "GET" && rest[0] === "status") {
|
||||
if (!ctx.getLoopRunStatus) {
|
||||
return { status: 503, body: { error: "auto-loop not available — attach to a chat session" } };
|
||||
}
|
||||
return { status: 200, body: { status: ctx.getLoopRunStatus() } };
|
||||
}
|
||||
|
||||
if (method === "POST" && rest[0] === "start") {
|
||||
if (!ctx.startAutoLoop) {
|
||||
return { status: 503, body: { error: "auto-loop start not wired" } };
|
||||
}
|
||||
const { intervalMs, prompt } = parseBody(body);
|
||||
if (typeof prompt !== "string" || !prompt.trim()) {
|
||||
return { status: 400, body: { error: "prompt must be a non-empty string" } };
|
||||
}
|
||||
if (
|
||||
typeof intervalMs !== "number" ||
|
||||
!Number.isFinite(intervalMs) ||
|
||||
intervalMs < MIN_INTERVAL_MS ||
|
||||
intervalMs > MAX_INTERVAL_MS
|
||||
) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error: `intervalMs must be a number in [${MIN_INTERVAL_MS}, ${MAX_INTERVAL_MS}] (5s..6h)`,
|
||||
},
|
||||
};
|
||||
}
|
||||
ctx.startAutoLoop(intervalMs, prompt.trim());
|
||||
ctx.audit?.({ ts: Date.now(), action: "auto-loop-start", payload: { intervalMs } });
|
||||
return { status: 200, body: { started: true } };
|
||||
}
|
||||
|
||||
if (method === "POST" && rest[0] === "stop") {
|
||||
if (!ctx.stopAutoLoop) {
|
||||
return { status: 503, body: { error: "auto-loop stop not wired" } };
|
||||
}
|
||||
ctx.stopAutoLoop();
|
||||
ctx.audit?.({ ts: Date.now(), action: "auto-loop-stop" });
|
||||
return { status: 200, body: { stopped: true } };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 405,
|
||||
body: { error: `method ${method} not supported on /api/loop/${rest[0] ?? ""}` },
|
||||
};
|
||||
}
|
||||
+55
-2
@@ -1,12 +1,43 @@
|
||||
/** GET snapshots the active modal so a fresh client paints what's already up; POST routes resolution into the same handlers the TUI uses. */
|
||||
|
||||
import type { DashboardContext } from "../context.js";
|
||||
import type { DashboardContext, PickerResolution } from "../context.js";
|
||||
import type { ApiResult } from "../router.js";
|
||||
|
||||
interface ResolveBody {
|
||||
kind?: unknown;
|
||||
choice?: unknown;
|
||||
text?: unknown;
|
||||
action?: unknown;
|
||||
id?: unknown;
|
||||
query?: unknown;
|
||||
}
|
||||
|
||||
function parsePickerResolution(body: ResolveBody): PickerResolution | { error: string } {
|
||||
const { action, id, text, query } = body;
|
||||
if (typeof action !== "string") return { error: "picker action required" };
|
||||
switch (action) {
|
||||
case "pick":
|
||||
case "delete":
|
||||
case "install":
|
||||
case "uninstall":
|
||||
if (typeof id !== "string" || !id) return { error: `picker ${action} requires id` };
|
||||
return { action, id };
|
||||
case "rename":
|
||||
if (typeof id !== "string" || !id) return { error: "picker rename requires id" };
|
||||
if (typeof text !== "string") return { error: "picker rename requires text" };
|
||||
return { action: "rename", id, text };
|
||||
case "new":
|
||||
return typeof text === "string" && text ? { action: "new", text } : { action: "new" };
|
||||
case "load-more":
|
||||
return { action: "load-more" };
|
||||
case "refine":
|
||||
if (typeof query !== "string") return { error: "picker refine requires query" };
|
||||
return { action: "refine", query };
|
||||
case "cancel":
|
||||
return { action: "cancel" };
|
||||
default:
|
||||
return { error: `unknown picker action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
function parseBody(raw: string): ResolveBody {
|
||||
@@ -33,7 +64,8 @@ export async function handleModal(
|
||||
}
|
||||
|
||||
if (method === "POST" && rest[0] === "resolve") {
|
||||
const { kind, choice, text } = parseBody(body);
|
||||
const parsed = parseBody(body);
|
||||
const { kind, choice, text } = parsed;
|
||||
if (kind === "shell") {
|
||||
if (!ctx.resolveShellConfirm) {
|
||||
return { status: 503, body: { error: "shell modal resolution not wired" } };
|
||||
@@ -121,6 +153,27 @@ export async function handleModal(
|
||||
ctx.resolveReviseConfirm(choice);
|
||||
return { status: 200, body: { resolved: true } };
|
||||
}
|
||||
if (kind === "picker") {
|
||||
if (!ctx.resolvePicker) {
|
||||
return { status: 503, body: { error: "picker modal resolution not wired" } };
|
||||
}
|
||||
const resolution = parsePickerResolution(parsed);
|
||||
if ("error" in resolution) {
|
||||
return { status: 400, body: { error: resolution.error } };
|
||||
}
|
||||
ctx.resolvePicker(resolution);
|
||||
return { status: 200, body: { resolved: true } };
|
||||
}
|
||||
if (kind === "viewer") {
|
||||
if (!ctx.resolveViewer) {
|
||||
return { status: 503, body: { error: "viewer modal resolution not wired" } };
|
||||
}
|
||||
if (parsed.action !== "close") {
|
||||
return { status: 400, body: { error: "viewer action must be close" } };
|
||||
}
|
||||
ctx.resolveViewer({ action: "close" });
|
||||
return { status: 200, body: { resolved: true } };
|
||||
}
|
||||
return { status: 400, body: { error: `unknown modal kind: ${String(kind)}` } };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DEEPSEEK_PRICING } from "../../telemetry/stats.js";
|
||||
import type { DashboardContext } from "../context.js";
|
||||
import type { ApiResult } from "../router.js";
|
||||
|
||||
export async function handleModels(
|
||||
method: string,
|
||||
_rest: string[],
|
||||
_body: string,
|
||||
ctx: DashboardContext,
|
||||
): Promise<ApiResult> {
|
||||
if (method !== "GET") return { status: 405, body: { error: "GET only" } };
|
||||
const models = ctx.getModels?.() ?? null;
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
models,
|
||||
current: ctx.loop?.model ?? null,
|
||||
/** USD per 1M tokens — same table the cost gauge uses. */
|
||||
pricing: DEEPSEEK_PRICING,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,9 @@ interface SettingsBody {
|
||||
preset?: unknown;
|
||||
reasoningEffort?: unknown;
|
||||
search?: unknown;
|
||||
model?: unknown;
|
||||
proNext?: unknown;
|
||||
budgetUsd?: unknown;
|
||||
}
|
||||
|
||||
function parseBody(raw: string): SettingsBody {
|
||||
@@ -39,6 +42,7 @@ export async function handleSettings(
|
||||
): Promise<ApiResult> {
|
||||
if (method === "GET") {
|
||||
const cfg = readConfig(ctx.configPath);
|
||||
const live = ctx.loop;
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
@@ -51,7 +55,10 @@ export async function handleSettings(
|
||||
search: cfg.search !== false,
|
||||
editMode: cfg.editMode ?? "review",
|
||||
session: cfg.session ?? null,
|
||||
model: ctx.loop?.model ?? null,
|
||||
model: live?.model ?? null,
|
||||
proNext: live?.proArmed ?? false,
|
||||
budgetUsd: live?.budgetUsd ?? null,
|
||||
sessionSpendUsd: ctx.getStats?.()?.totalCostUsd ?? null,
|
||||
// Hint to the SPA which fields require restart.
|
||||
appliesAt: {
|
||||
apiKey: "next-session",
|
||||
@@ -59,6 +66,9 @@ export async function handleSettings(
|
||||
preset: "next-session",
|
||||
reasoningEffort: "next-turn",
|
||||
search: "next-session",
|
||||
model: "next-turn",
|
||||
proNext: "next-turn",
|
||||
budgetUsd: "live",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -127,6 +137,43 @@ export async function handleSettings(
|
||||
cfg.search = fields.search;
|
||||
changed.push("search");
|
||||
}
|
||||
let modelPendingLive: string | null = null;
|
||||
let proNextPending: boolean | null = null;
|
||||
let budgetPending: number | null | undefined;
|
||||
if (fields.model !== undefined) {
|
||||
if (typeof fields.model !== "string" || !fields.model.trim()) {
|
||||
return { status: 400, body: { error: "model must be a non-empty string" } };
|
||||
}
|
||||
// Model is live-only (not in ReasonixConfig). Same as /model <id> slash — disk
|
||||
// pickup goes through preset / startup flag, not direct cfg.model.
|
||||
modelPendingLive = fields.model.trim();
|
||||
changed.push("model");
|
||||
}
|
||||
if (fields.proNext !== undefined) {
|
||||
if (typeof fields.proNext !== "boolean") {
|
||||
return { status: 400, body: { error: "proNext must be a boolean" } };
|
||||
}
|
||||
// Not persisted: arming is per-turn ephemeral. Live-only side effect.
|
||||
proNextPending = fields.proNext;
|
||||
changed.push("proNext");
|
||||
}
|
||||
if (fields.budgetUsd !== undefined) {
|
||||
if (fields.budgetUsd === null) {
|
||||
budgetPending = null;
|
||||
} else if (
|
||||
typeof fields.budgetUsd === "number" &&
|
||||
fields.budgetUsd > 0 &&
|
||||
Number.isFinite(fields.budgetUsd)
|
||||
) {
|
||||
budgetPending = fields.budgetUsd;
|
||||
} else {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "budgetUsd must be null or a positive finite number" },
|
||||
};
|
||||
}
|
||||
changed.push("budgetUsd");
|
||||
}
|
||||
|
||||
if (changed.length > 0) {
|
||||
writeConfig(cfg, ctx.configPath);
|
||||
@@ -137,6 +184,9 @@ export async function handleSettings(
|
||||
if (langPending) setLanguage(langPending);
|
||||
if (presetPendingLive) ctx.applyPresetLive?.(presetPendingLive);
|
||||
if (effortPendingLive) ctx.applyEffortLive?.(effortPendingLive);
|
||||
if (modelPendingLive) ctx.applyModelLive?.(modelPendingLive);
|
||||
if (proNextPending !== null) ctx.setProNextLive?.(proNextPending);
|
||||
if (budgetPending !== undefined) ctx.setBudgetUsdLive?.(budgetPending);
|
||||
ctx.audit?.({ ts: Date.now(), action: "set-settings", payload: { fields: changed } });
|
||||
}
|
||||
return { status: 200, body: { changed } };
|
||||
|
||||
@@ -37,6 +37,25 @@ export interface DashboardContext {
|
||||
applyPresetLive?: (name: string) => void;
|
||||
/** Side-channel to live loop — settings POST persists, this flips the running session. */
|
||||
applyEffortLive?: (effort: "high" | "max") => void;
|
||||
/** Same model swap path /model <id> takes — live + persisted. */
|
||||
applyModelLive?: (model: string) => void;
|
||||
/** Cached model catalog. Null = in flight / failed; `[]` = API answered empty. */
|
||||
getModels?: () => string[] | null;
|
||||
/** One-shot v4-pro arming for the next turn. `armed=false` cancels a pending arm. */
|
||||
setProNextLive?: (armed: boolean) => void;
|
||||
/** Session USD cap; null disables. Re-arms the 80% warning latch. */
|
||||
setBudgetUsdLive?: (usd: number | null) => void;
|
||||
/** Auto-resubmit timer status — same shape `useLoopMode` exposes to slash handlers. */
|
||||
getLoopRunStatus?: () => {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
nextFireMs: number;
|
||||
} | null;
|
||||
/** Start the auto-resubmit timer. Same path the `/loop` slash takes. */
|
||||
startAutoLoop?: (intervalMs: number, prompt: string) => void;
|
||||
/** Clear the auto-resubmit timer. */
|
||||
stopAutoLoop?: () => void;
|
||||
/** Endpoints don't write the audit log themselves so tests can swap the implementation. */
|
||||
audit?: (entry: AuditEntry) => void;
|
||||
|
||||
@@ -57,6 +76,10 @@ export interface DashboardContext {
|
||||
resolveEditReview?: (choice: "apply" | "reject" | "apply-rest-of-turn" | "flip-to-auto") => void;
|
||||
resolveCheckpointConfirm?: (choice: "continue" | "revise" | "stop", text?: string) => void;
|
||||
resolveReviseConfirm?: (choice: "accept" | "reject") => void;
|
||||
/** Active picker (sessions / checkpoints / mcp marketplace / …) resolves into the live TUI component via a runtime ref. */
|
||||
resolvePicker?: (resolution: PickerResolution) => void;
|
||||
/** Active read-only viewer (replay-plan / …) — only `close` is meaningful since the viewer carries no selection state. */
|
||||
resolveViewer?: (resolution: { action: "close" }) => void;
|
||||
|
||||
reloadHooks?: () => number;
|
||||
reloadMcp?: () => Promise<number>;
|
||||
@@ -74,6 +97,31 @@ export type ChoiceResolution =
|
||||
| { kind: "custom"; text: string }
|
||||
| { kind: "cancel" };
|
||||
|
||||
/** Web-driven action against the picker that's currently up. `refine` and `load-more` keep the picker open; everything else closes it. */
|
||||
export type PickerResolution =
|
||||
| { action: "pick"; id: string }
|
||||
| { action: "delete"; id: string }
|
||||
| { action: "rename"; id: string; text: string }
|
||||
| { action: "new"; text?: string }
|
||||
| { action: "install"; id: string }
|
||||
| { action: "uninstall"; id: string }
|
||||
| { action: "load-more" }
|
||||
| { action: "refine"; query: string }
|
||||
| { action: "cancel" };
|
||||
|
||||
export type PickerAction = PickerResolution["action"];
|
||||
|
||||
export interface PickerItem {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Secondary line — relative timestamp, branch, description. */
|
||||
subtitle?: string;
|
||||
/** Right-aligned tag — installed / active / source. */
|
||||
badge?: string;
|
||||
/** Trailing meta — file count, popularity, cost. */
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
/** Total turns this session. */
|
||||
turns: number;
|
||||
@@ -141,6 +189,28 @@ export type ActiveModal =
|
||||
risk?: "low" | "med" | "high";
|
||||
}>;
|
||||
summary?: string;
|
||||
}
|
||||
| {
|
||||
kind: "picker";
|
||||
/** Discriminator for the underlying picker (sessions / checkpoints / mcp-marketplace / …). Drives empty-state copy + icon on the SPA. */
|
||||
pickerKind: string;
|
||||
title: string;
|
||||
query?: string;
|
||||
items: PickerItem[];
|
||||
actions: PickerAction[];
|
||||
hasMore?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
| {
|
||||
kind: "viewer";
|
||||
/** Discriminator for the underlying viewer (replay-plan / …). */
|
||||
viewerKind: string;
|
||||
title: string;
|
||||
/** Markdown / plain text body. */
|
||||
body?: string;
|
||||
/** Structured plan steps when viewerKind === "replay-plan". */
|
||||
steps?: Array<{ id: string; title: string; status: "done" | "queued" }>;
|
||||
meta?: string;
|
||||
};
|
||||
|
||||
/** One row of the conversation as the SPA renders it. */
|
||||
|
||||
@@ -4,10 +4,12 @@ import { handleFiles } from "./api/files.js";
|
||||
import { handleHealth } from "./api/health.js";
|
||||
import { handleHooks } from "./api/hooks.js";
|
||||
import { handleIndexConfig } from "./api/index-config.js";
|
||||
import { handleLoop } from "./api/loop.js";
|
||||
import { handleMcp } from "./api/mcp.js";
|
||||
import { handleMemory } from "./api/memory.js";
|
||||
import { handleMessages } from "./api/messages.js";
|
||||
import { handleModal } from "./api/modal.js";
|
||||
import { handleModels } from "./api/models.js";
|
||||
import { handleOverview } from "./api/overview.js";
|
||||
import { handlePermissions } from "./api/permissions.js";
|
||||
import { handlePlans } from "./api/plans.js";
|
||||
@@ -81,6 +83,10 @@ export async function handleApi(
|
||||
return await handleSlash(method, rest, body, ctx);
|
||||
case "files":
|
||||
return await handleFiles(method, rest, body, ctx);
|
||||
case "loop":
|
||||
return await handleLoop(method, rest, body, ctx);
|
||||
case "models":
|
||||
return await handleModels(method, rest, body, ctx);
|
||||
default:
|
||||
return { status: 404, body: { error: `no such endpoint: /${head}` } };
|
||||
}
|
||||
|
||||
+13
-10
@@ -339,11 +339,11 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
fn: async (args: { path?: string; pattern: string; include_deps?: boolean }) =>
|
||||
fn: async (args: { path?: string; pattern: string; include_deps?: boolean }, toolCtx) =>
|
||||
searchFiles(
|
||||
{ rootDir, maxListBytes, skipDirNames: SKIP_DIR_NAMES },
|
||||
safePath(args.path ?? "."),
|
||||
args,
|
||||
{ ...args, signal: toolCtx?.signal },
|
||||
),
|
||||
});
|
||||
|
||||
@@ -381,13 +381,16 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
fn: async (args: {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
case_sensitive?: boolean;
|
||||
include_deps?: boolean;
|
||||
}) =>
|
||||
fn: async (
|
||||
args: {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
case_sensitive?: boolean;
|
||||
include_deps?: boolean;
|
||||
},
|
||||
toolCtx,
|
||||
) =>
|
||||
searchContent(
|
||||
{
|
||||
rootDir,
|
||||
@@ -397,7 +400,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
nameMatch: compileNameFilter(typeof args.glob === "string" ? args.glob : null),
|
||||
},
|
||||
safePath(args.path ?? "."),
|
||||
args,
|
||||
{ ...args, signal: toolCtx?.signal },
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
+16
-1
@@ -10,6 +10,11 @@ export interface SearchContext {
|
||||
nameMatch: ((name: string, rel: string) => boolean) | null;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (!signal?.aborted) return;
|
||||
throw new DOMException("search aborted by user", "AbortError");
|
||||
}
|
||||
|
||||
function displayRel(rootDir: string, full: string): string {
|
||||
return pathMod.relative(rootDir, full).replaceAll("\\", "/");
|
||||
}
|
||||
@@ -17,8 +22,9 @@ function displayRel(rootDir: string, full: string): string {
|
||||
export async function searchFiles(
|
||||
ctx: Pick<SearchContext, "rootDir" | "maxListBytes" | "skipDirNames">,
|
||||
startAbs: string,
|
||||
args: { pattern: string; include_deps?: boolean },
|
||||
args: { pattern: string; include_deps?: boolean; signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
throwIfAborted(args.signal);
|
||||
const needle = args.pattern.toLowerCase();
|
||||
const includeDeps = args.include_deps === true;
|
||||
let re: RegExp | null = null;
|
||||
@@ -30,6 +36,7 @@ export async function searchFiles(
|
||||
const matches: string[] = [];
|
||||
let totalBytes = 0;
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
throwIfAborted(args.signal);
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
@@ -37,6 +44,7 @@ export async function searchFiles(
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
throwIfAborted(args.signal);
|
||||
const full = pathMod.join(dir, e.name);
|
||||
const lower = e.name.toLowerCase();
|
||||
const hit = re ? re.test(e.name) : lower.includes(needle);
|
||||
@@ -66,8 +74,10 @@ export async function searchContent(
|
||||
pattern: string;
|
||||
case_sensitive?: boolean;
|
||||
include_deps?: boolean;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<string> {
|
||||
throwIfAborted(args.signal);
|
||||
const caseSensitive = args.case_sensitive === true;
|
||||
const includeDeps = args.include_deps === true;
|
||||
// Try the pattern as a regex first (lets the model say `\bdispatch\(`
|
||||
@@ -88,6 +98,7 @@ export async function searchContent(
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
if (truncated) return;
|
||||
throwIfAborted(args.signal);
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
@@ -96,6 +107,7 @@ export async function searchContent(
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (truncated) return;
|
||||
throwIfAborted(args.signal);
|
||||
if (e.isDirectory()) {
|
||||
if (!includeDeps && ctx.skipDirNames.has(e.name)) continue;
|
||||
await walk(pathMod.join(dir, e.name));
|
||||
@@ -115,6 +127,7 @@ export async function searchContent(
|
||||
}
|
||||
let raw: Buffer;
|
||||
try {
|
||||
throwIfAborted(args.signal);
|
||||
const st = await fh.stat();
|
||||
// Per-file size cap so a 50MB log doesn't dominate the search.
|
||||
// Anything legitimately interesting fits in 2 MB; bigger files
|
||||
@@ -129,6 +142,7 @@ export async function searchContent(
|
||||
continue;
|
||||
}
|
||||
await fh.close();
|
||||
throwIfAborted(args.signal);
|
||||
// Content-based binary sniff: NUL byte in the first 8KB. Catches
|
||||
// binaries with .json or .txt extensions (yes, this happens).
|
||||
const firstNul = raw.indexOf(0);
|
||||
@@ -137,6 +151,7 @@ export async function searchContent(
|
||||
const rel = displayRel(ctx.rootDir, full);
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
throwIfAborted(args.signal);
|
||||
const line = lines[li]!;
|
||||
const lineForCheck = caseSensitive ? line : line.toLowerCase();
|
||||
const hit = re ? re.test(line) : lineForCheck.includes(needle);
|
||||
|
||||
@@ -42,6 +42,13 @@ const config = {
|
||||
break: 50,
|
||||
},
|
||||
|
||||
// Reporters — JSON gives us structured data for automated analysis.
|
||||
// Keep "progress" so the progress bar doesn't vanish during the run.
|
||||
reporters: ["progress", "clear-text", "html", "json"],
|
||||
jsonReporter: {
|
||||
fileName: "reports/mutation/mutation.json",
|
||||
},
|
||||
|
||||
// Concurrency; adjust based on your machine.
|
||||
concurrency: 4,
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const renderMock = vi.fn();
|
||||
const loadApiKeyMock = vi.fn(() => "sk-test");
|
||||
const readConfigMock = vi.fn(() => ({ mcpDisabled: [] as string[] }));
|
||||
const searchEnabledMock = vi.fn(() => false);
|
||||
const loadDotenvMock = vi.fn();
|
||||
const resolveSessionMock = vi.fn(() => ({ resolved: "session-1" }));
|
||||
const listSessionsForWorkspaceMock = vi.fn(() => [] as string[]);
|
||||
const initializeMock = vi.fn(async () => undefined);
|
||||
const closeMock = vi.fn(async () => undefined);
|
||||
const bridgeMcpToolsMock = vi.fn(async (_client: unknown, opts: any) => ({
|
||||
registeredNames: [],
|
||||
env: {
|
||||
registry: opts.registry,
|
||||
host: opts.host,
|
||||
prefix: opts.namePrefix ?? "",
|
||||
maxResultChars: 32_000,
|
||||
tracker: null,
|
||||
},
|
||||
}));
|
||||
const inspectMcpServerMock = vi.fn(async () => ({
|
||||
protocolVersion: "2024-11-05",
|
||||
serverInfo: { name: "fs-server", version: "1.0.0" },
|
||||
capabilities: { tools: {} },
|
||||
tools: { supported: true as const, items: [] },
|
||||
resources: { supported: false as const, reason: "method not found" },
|
||||
prompts: { supported: false as const, reason: "method not found" },
|
||||
elapsedMs: 42,
|
||||
}));
|
||||
const parseMcpSpecMock = vi.fn((raw: string) => ({
|
||||
name: raw.split("=")[0] ?? "anon",
|
||||
transport: "stdio" as const,
|
||||
command: "mock-mcp",
|
||||
args: [],
|
||||
}));
|
||||
|
||||
class FakeMcpClient {
|
||||
protocolVersion = "2024-11-05";
|
||||
serverInfo = { name: "fs-server", version: "1.0.0" };
|
||||
serverCapabilities = { tools: {} };
|
||||
|
||||
async initialize() {
|
||||
return initializeMock();
|
||||
}
|
||||
|
||||
async close() {
|
||||
return closeMock();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTransport {}
|
||||
|
||||
return {
|
||||
bridgeMcpToolsMock,
|
||||
closeMock,
|
||||
FakeMcpClient,
|
||||
FakeTransport,
|
||||
initializeMock,
|
||||
inspectMcpServerMock,
|
||||
listSessionsForWorkspaceMock,
|
||||
loadApiKeyMock,
|
||||
loadDotenvMock,
|
||||
parseMcpSpecMock,
|
||||
readConfigMock,
|
||||
renderMock,
|
||||
resolveSessionMock,
|
||||
searchEnabledMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("ink", () => ({
|
||||
render: mocks.renderMock,
|
||||
}));
|
||||
|
||||
vi.mock("../src/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadApiKey: mocks.loadApiKeyMock,
|
||||
readConfig: mocks.readConfigMock,
|
||||
searchEnabled: mocks.searchEnabledMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/env.js", () => ({
|
||||
loadDotenv: mocks.loadDotenvMock,
|
||||
}));
|
||||
|
||||
vi.mock("../src/memory/session.js", () => ({
|
||||
deleteSession: vi.fn(),
|
||||
listSessionsForWorkspace: mocks.listSessionsForWorkspaceMock,
|
||||
renameSession: vi.fn(),
|
||||
resolveSession: mocks.resolveSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../src/mcp/client.js", () => ({
|
||||
McpClient: mocks.FakeMcpClient,
|
||||
}));
|
||||
|
||||
vi.mock("../src/mcp/inspect.js", () => ({
|
||||
inspectMcpServer: mocks.inspectMcpServerMock,
|
||||
}));
|
||||
|
||||
vi.mock("../src/mcp/registry.js", () => ({
|
||||
bridgeMcpTools: mocks.bridgeMcpToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("../src/mcp/spec.js", () => ({
|
||||
parseMcpSpec: mocks.parseMcpSpecMock,
|
||||
}));
|
||||
|
||||
vi.mock("../src/mcp/sse.js", () => ({
|
||||
SseTransport: mocks.FakeTransport,
|
||||
}));
|
||||
|
||||
vi.mock("../src/mcp/stdio.js", () => ({
|
||||
StdioTransport: mocks.FakeTransport,
|
||||
}));
|
||||
|
||||
vi.mock("../src/mcp/streamable-http.js", () => ({
|
||||
StreamableHttpTransport: mocks.FakeTransport,
|
||||
}));
|
||||
|
||||
async function captureStartupState(opts?: {
|
||||
readConfig?: { mcpDisabled?: string[] };
|
||||
initializeError?: Error;
|
||||
bridgeError?: Error;
|
||||
}) {
|
||||
vi.resetModules();
|
||||
mocks.renderMock.mockReset();
|
||||
mocks.loadDotenvMock.mockClear();
|
||||
mocks.loadApiKeyMock.mockClear();
|
||||
mocks.initializeMock.mockReset();
|
||||
mocks.closeMock.mockReset();
|
||||
mocks.bridgeMcpToolsMock.mockReset();
|
||||
mocks.inspectMcpServerMock.mockReset();
|
||||
mocks.parseMcpSpecMock.mockReset();
|
||||
mocks.readConfigMock.mockReset();
|
||||
mocks.listSessionsForWorkspaceMock.mockReset();
|
||||
mocks.resolveSessionMock.mockReset();
|
||||
mocks.searchEnabledMock.mockReset();
|
||||
|
||||
mocks.readConfigMock.mockReturnValue(opts?.readConfig ?? { mcpDisabled: [] });
|
||||
mocks.searchEnabledMock.mockReturnValue(false);
|
||||
mocks.listSessionsForWorkspaceMock.mockReturnValue([]);
|
||||
mocks.resolveSessionMock.mockReturnValue({ resolved: "session-1" });
|
||||
mocks.parseMcpSpecMock.mockImplementation((raw: string) => ({
|
||||
name: raw.split("=")[0] ?? "anon",
|
||||
transport: "stdio" as const,
|
||||
command: "mock-mcp",
|
||||
args: [],
|
||||
}));
|
||||
mocks.initializeMock.mockImplementation(async () => {
|
||||
if (opts?.initializeError) throw opts.initializeError;
|
||||
});
|
||||
mocks.bridgeMcpToolsMock.mockImplementation(async (_client: unknown, bridgeOpts: any) => {
|
||||
if (opts?.bridgeError) throw opts.bridgeError;
|
||||
return {
|
||||
registeredNames: [],
|
||||
env: {
|
||||
registry: bridgeOpts.registry,
|
||||
host: bridgeOpts.host,
|
||||
prefix: bridgeOpts.namePrefix ?? "",
|
||||
maxResultChars: 32_000,
|
||||
tracker: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
mocks.inspectMcpServerMock.mockImplementation(async () => ({
|
||||
protocolVersion: "2024-11-05",
|
||||
serverInfo: { name: "fs-server", version: "1.0.0" },
|
||||
capabilities: { tools: {} },
|
||||
tools: { supported: true as const, items: [] },
|
||||
resources: { supported: false as const, reason: "method not found" },
|
||||
prompts: { supported: false as const, reason: "method not found" },
|
||||
elapsedMs: 42,
|
||||
}));
|
||||
|
||||
let capturedProps: Record<string, unknown> | null = null;
|
||||
mocks.renderMock.mockImplementation((element: { props: Record<string, unknown> }) => {
|
||||
capturedProps = element.props;
|
||||
return { waitUntilExit: async () => undefined };
|
||||
});
|
||||
|
||||
const [{ chatCommand }, { ToolRegistry }] = await Promise.all([
|
||||
import("../src/cli/commands/chat.js"),
|
||||
import("../src/tools.js"),
|
||||
]);
|
||||
|
||||
await chatCommand({
|
||||
model: "deepseek-chat",
|
||||
system: "s",
|
||||
mcp: ["fs=npx -y @scope/fs /tmp"],
|
||||
seedTools: new ToolRegistry(),
|
||||
});
|
||||
|
||||
expect(capturedProps).not.toBeNull();
|
||||
return capturedProps as {
|
||||
mcpServers: Array<{ label: string; spec: string }>;
|
||||
mcpSpecs: string[];
|
||||
};
|
||||
}
|
||||
|
||||
describe("chatCommand MCP startup summary states", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("passes live bridged servers into the initial MCP props", async () => {
|
||||
const props = await captureStartupState();
|
||||
|
||||
expect(props.mcpSpecs).toEqual(["fs=npx -y @scope/fs /tmp"]);
|
||||
expect(props.mcpServers).toHaveLength(1);
|
||||
expect(props.mcpServers[0]).toMatchObject({
|
||||
label: "fs",
|
||||
spec: "fs=npx -y @scope/fs /tmp",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves disabled startup specs for marketplace fallback even with no live servers", async () => {
|
||||
const props = await captureStartupState({
|
||||
readConfig: { mcpDisabled: ["fs"] },
|
||||
});
|
||||
|
||||
expect(props.mcpSpecs).toEqual(["fs=npx -y @scope/fs /tmp"]);
|
||||
expect(props.mcpServers).toEqual([]);
|
||||
expect(mocks.bridgeMcpToolsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves unbridged startup specs when startup fails before a live summary exists", async () => {
|
||||
const props = await captureStartupState({
|
||||
initializeError: new Error("spawn failed"),
|
||||
});
|
||||
|
||||
expect(props.mcpSpecs).toEqual(["fs=npx -y @scope/fs /tmp"]);
|
||||
expect(props.mcpServers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -215,6 +215,32 @@ describe("config", () => {
|
||||
expect(clearProjectShellAllowed("/empty", path)).toBe(0);
|
||||
});
|
||||
|
||||
it.runIf(process.platform === "win32")(
|
||||
"matches project keys case-insensitively on Windows so cross-shell rootDir casing doesn't lose entries (#402)",
|
||||
() => {
|
||||
addProjectShellAllowed("F:\\Reasonix", "gh", path);
|
||||
expect(loadProjectShellAllowed("f:\\reasonix", path)).toContain("gh");
|
||||
expect(loadProjectShellAllowed("F:\\REASONIX", path)).toContain("gh");
|
||||
// Mutations through any-cased rootDir consolidate onto the original key.
|
||||
addProjectShellAllowed("f:\\reasonix", "deploy", path);
|
||||
expect(loadProjectShellAllowed("F:\\Reasonix", path)).toEqual(["gh", "deploy"]);
|
||||
expect(Object.keys(readConfig(path).projects ?? {})).toEqual(["F:\\Reasonix"]);
|
||||
expect(removeProjectShellAllowed("f:\\REASONIX", "gh", path)).toBe(true);
|
||||
expect(loadProjectShellAllowed("F:\\Reasonix", path)).toEqual(["deploy"]);
|
||||
expect(clearProjectShellAllowed("F:\\REASONIX", path)).toBe(1);
|
||||
expect(loadProjectShellAllowed("F:\\Reasonix", path)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"keeps project key matching case-sensitive on non-Windows platforms",
|
||||
() => {
|
||||
addProjectShellAllowed("/home/foo/repo", "gh", path);
|
||||
expect(loadProjectShellAllowed("/home/foo/repo", path)).toContain("gh");
|
||||
expect(loadProjectShellAllowed("/home/FOO/repo", path)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("loadEditMode defaults to 'review' when unset", () => {
|
||||
expect(loadEditMode(path)).toBe("review");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
QUICK_CAPS_USD,
|
||||
budgetTone,
|
||||
bumpSuggestions,
|
||||
deriveBudgetState,
|
||||
} from "../dashboard/src/lib/budget.js";
|
||||
|
||||
describe("deriveBudgetState", () => {
|
||||
it("returns off when cap is null / 0 / negative", () => {
|
||||
expect(deriveBudgetState(null, 0.5)).toEqual({ kind: "off", spent: 0.5 });
|
||||
expect(deriveBudgetState(0, 0.5)).toEqual({ kind: "off", spent: 0.5 });
|
||||
expect(deriveBudgetState(-1, 0.5)).toEqual({ kind: "off", spent: 0.5 });
|
||||
expect(deriveBudgetState(undefined, undefined)).toEqual({ kind: "off", spent: 0 });
|
||||
});
|
||||
|
||||
it("returns running below 80%", () => {
|
||||
expect(deriveBudgetState(5, 0)).toMatchObject({ kind: "running", cap: 5, spent: 0, pct: 0 });
|
||||
expect(deriveBudgetState(5, 3.99)).toMatchObject({ kind: "running" });
|
||||
});
|
||||
|
||||
it("returns warn at exactly 80% and up to 99.99%", () => {
|
||||
expect(deriveBudgetState(5, 4.0)).toMatchObject({ kind: "warn", pct: 80 });
|
||||
expect(deriveBudgetState(5, 4.99)).toMatchObject({ kind: "warn" });
|
||||
});
|
||||
|
||||
it("returns exhausted at 100% and above", () => {
|
||||
expect(deriveBudgetState(5, 5.0)).toMatchObject({ kind: "exhausted", pct: 100 });
|
||||
expect(deriveBudgetState(5, 7.5)).toMatchObject({ kind: "exhausted", pct: 150 });
|
||||
});
|
||||
|
||||
it("clamps a negative spent reading to 0", () => {
|
||||
expect(deriveBudgetState(5, -1)).toMatchObject({ kind: "running", spent: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("budgetTone", () => {
|
||||
it("maps each state to the matching CSS class", () => {
|
||||
expect(budgetTone({ kind: "off", spent: 0 })).toBe("");
|
||||
expect(budgetTone({ kind: "running", cap: 5, spent: 0, pct: 0 })).toBe("");
|
||||
expect(budgetTone({ kind: "warn", cap: 5, spent: 4, pct: 80 })).toBe("warn");
|
||||
expect(budgetTone({ kind: "exhausted", cap: 5, spent: 5, pct: 100 })).toBe("err");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bumpSuggestions", () => {
|
||||
it("rounds small caps (<$1) to one decimal, snapping the 4× into the next bucket", () => {
|
||||
const r = bumpSuggestions(0.4);
|
||||
// 0.4 × 1.5 = 0.6 → 0.6, 0.4 × 2 = 0.8 → 0.8, 0.4 × 4 = 1.6 → snaps to half-dollar 2.
|
||||
expect(r).toEqual([0.6, 0.8, 2]);
|
||||
});
|
||||
|
||||
it("rounds mid caps (<$10) to half-dollar", () => {
|
||||
const r = bumpSuggestions(5);
|
||||
expect(r).toEqual([7.5, 10, 20]);
|
||||
});
|
||||
|
||||
it("rounds large caps to whole dollars", () => {
|
||||
const r = bumpSuggestions(25);
|
||||
expect(r).toEqual([38, 50, 100]);
|
||||
});
|
||||
|
||||
it("rounds very large caps to nearest $5", () => {
|
||||
const r = bumpSuggestions(200);
|
||||
expect(r).toEqual([300, 400, 800]);
|
||||
});
|
||||
|
||||
it("returns empty for non-positive cap", () => {
|
||||
expect(bumpSuggestions(0)).toEqual([]);
|
||||
expect(bumpSuggestions(-1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("QUICK_CAPS_USD", () => {
|
||||
it("ships sensible round dollar starting points", () => {
|
||||
expect(QUICK_CAPS_USD).toEqual([1, 5, 10, 25, 50]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
INTERVAL_PRESETS_MS,
|
||||
formatRemaining,
|
||||
parseCustomInterval,
|
||||
} from "../dashboard/src/lib/loop-control.js";
|
||||
|
||||
describe("INTERVAL_PRESETS_MS", () => {
|
||||
it("covers the 5s..6h window with sensible labels", () => {
|
||||
const labels = INTERVAL_PRESETS_MS.map((p) => p.label);
|
||||
expect(labels).toEqual(["30s", "1m", "5m", "15m", "1h", "6h"]);
|
||||
for (const p of INTERVAL_PRESETS_MS) {
|
||||
expect(p.ms).toBeGreaterThanOrEqual(5_000);
|
||||
expect(p.ms).toBeLessThanOrEqual(6 * 60 * 60_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCustomInterval", () => {
|
||||
it("converts seconds / minutes / hours to ms", () => {
|
||||
expect(parseCustomInterval("30", "s")).toBe(30_000);
|
||||
expect(parseCustomInterval("5", "m")).toBe(300_000);
|
||||
expect(parseCustomInterval("2", "h")).toBe(7_200_000);
|
||||
});
|
||||
|
||||
it("accepts decimals", () => {
|
||||
expect(parseCustomInterval("1.5", "m")).toBe(90_000);
|
||||
});
|
||||
|
||||
it("rejects non-positive and non-numeric values", () => {
|
||||
expect(parseCustomInterval("0", "s")).toBeNull();
|
||||
expect(parseCustomInterval("-1", "s")).toBeNull();
|
||||
expect(parseCustomInterval("", "s")).toBeNull();
|
||||
expect(parseCustomInterval("abc", "s")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects under 5s", () => {
|
||||
expect(parseCustomInterval("4", "s")).toBeNull();
|
||||
expect(parseCustomInterval("5", "s")).toBe(5_000);
|
||||
});
|
||||
|
||||
it("rejects over 6h", () => {
|
||||
expect(parseCustomInterval("7", "h")).toBeNull();
|
||||
expect(parseCustomInterval("6", "h")).toBe(6 * 60 * 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRemaining", () => {
|
||||
it("renders sub-minute as seconds", () => {
|
||||
expect(formatRemaining(0)).toBe("0s");
|
||||
expect(formatRemaining(12_000)).toBe("12s");
|
||||
expect(formatRemaining(59_999)).toBe("59s");
|
||||
});
|
||||
|
||||
it("renders sub-hour as `Xm Ys` (drops 0s)", () => {
|
||||
expect(formatRemaining(60_000)).toBe("1m");
|
||||
expect(formatRemaining(72_000)).toBe("1m 12s");
|
||||
expect(formatRemaining(5 * 60_000)).toBe("5m");
|
||||
});
|
||||
|
||||
it("renders multi-hour as `Xh Ym` (drops 0m, drops seconds)", () => {
|
||||
expect(formatRemaining(60 * 60_000)).toBe("1h");
|
||||
expect(formatRemaining(2 * 60 * 60_000 + 45 * 60_000)).toBe("2h 45m");
|
||||
expect(formatRemaining(3 * 60 * 60_000 + 30 * 60_000 + 15_000)).toBe("3h 30m");
|
||||
});
|
||||
|
||||
it("clamps negatives to 0s (countdown can race past zero between polls)", () => {
|
||||
expect(formatRemaining(-500)).toBe("0s");
|
||||
expect(formatRemaining(-99_999)).toBe("0s");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
import { lineDiff, registerFilesystemTools } from "../src/tools/filesystem.js";
|
||||
import { compileNameFilter, displayRel } from "../src/tools/filesystem.js";
|
||||
@@ -274,6 +274,32 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
);
|
||||
expect(out).toContain("node_modules/lib/marker.ts");
|
||||
});
|
||||
|
||||
it("honors AbortSignal during recursive search", async () => {
|
||||
await fs.mkdir(join(root, "src", "nested"), { recursive: true });
|
||||
await fs.writeFile(join(root, "src", "nested", "marker.ts"), "x");
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const originalReaddir = fs.readdir.bind(fs);
|
||||
let readdirCalls = 0;
|
||||
const spy = vi
|
||||
.spyOn(fs, "readdir")
|
||||
.mockImplementation(async (...args: Parameters<typeof fs.readdir>) => {
|
||||
const result = await originalReaddir(...args);
|
||||
readdirCalls++;
|
||||
if (readdirCalls === 2) ctrl.abort();
|
||||
return result;
|
||||
});
|
||||
|
||||
try {
|
||||
const out = await tools.dispatch("search_files", JSON.stringify({ pattern: "marker" }), {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
expect(out).toMatch(/aborted/i);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("search_content", () => {
|
||||
@@ -386,6 +412,34 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
|
||||
expect(out).not.toMatch(/src[\\]cli/);
|
||||
});
|
||||
|
||||
it("honors AbortSignal during recursive content search", async () => {
|
||||
await fs.mkdir(join(root, "src", "nested"), { recursive: true });
|
||||
await fs.writeFile(join(root, "src", "nested", "deep.ts"), "export const z = 3;\n");
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const originalReaddir = fs.readdir.bind(fs);
|
||||
let readdirCalls = 0;
|
||||
const spy = vi
|
||||
.spyOn(fs, "readdir")
|
||||
.mockImplementation(async (...args: Parameters<typeof fs.readdir>) => {
|
||||
const result = await originalReaddir(...args);
|
||||
readdirCalls++;
|
||||
if (readdirCalls === 2) ctrl.abort();
|
||||
return result;
|
||||
});
|
||||
|
||||
try {
|
||||
const out = await tools.dispatch(
|
||||
"search_content",
|
||||
JSON.stringify({ pattern: "export const" }),
|
||||
{ signal: ctrl.signal },
|
||||
);
|
||||
expect(out).toMatch(/aborted/i);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
describe("glob filter", () => {
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(join(root, "src", "ui"), { recursive: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Loop error decorator — context-overflow gets a user hint; everything else passes through. */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { setLanguageRuntime } from "../src/i18n/index.js";
|
||||
import { formatLoopError, healLoadedMessages, stripHallucinatedToolMarkup } from "../src/loop.js";
|
||||
import type { ChatMessage } from "../src/types.js";
|
||||
|
||||
@@ -77,11 +78,69 @@ describe("formatLoopError", () => {
|
||||
expect(out).toMatch(/131k/);
|
||||
});
|
||||
|
||||
it("tolerates an empty or malformed JSON body on the error payload", () => {
|
||||
const raw = new Error("DeepSeek 500: ");
|
||||
it("503 with no probe → DS-side outage notice + retry hint, no probe-specific line", () => {
|
||||
const raw = new Error('DeepSeek 503: {"error":{"message":"Service unavailable"}}');
|
||||
const out = formatLoopError(raw);
|
||||
// 500 isn't in the remapped set, so it passes through as-is
|
||||
expect(out).toBe("DeepSeek 500: ");
|
||||
expect(out).toMatch(/service unavailable \(503\)/);
|
||||
expect(out).toMatch(/DeepSeek-side problem, not Reasonix/);
|
||||
expect(out).toMatch(/Already retried 4×/);
|
||||
expect(out).toContain("status.deepseek.com");
|
||||
expect(out).not.toMatch(/main API answered/);
|
||||
expect(out).not.toMatch(/unreachable from your network/);
|
||||
});
|
||||
|
||||
it("503 with reachable probe → tells user DS chat endpoint is sick but main API is up", () => {
|
||||
const raw = new Error("DeepSeek 503: ");
|
||||
const out = formatLoopError(raw, { reachable: true });
|
||||
expect(out).toMatch(/main API answered our health check/);
|
||||
expect(out).toMatch(/partial outage on their side/);
|
||||
expect(out).not.toMatch(/check your network/);
|
||||
});
|
||||
|
||||
it("503 with unreachable probe → tells user DS or their network is down, network-first hint", () => {
|
||||
const raw = new Error("DeepSeek 503: ");
|
||||
const out = formatLoopError(raw, { reachable: false });
|
||||
expect(out).toMatch(/unreachable from your network/);
|
||||
expect(out).toMatch(/check your network/);
|
||||
expect(out).not.toMatch(/main API answered/);
|
||||
});
|
||||
|
||||
it("500/502/504 also remap to the DS-side outage notice", () => {
|
||||
for (const status of [500, 502, 504]) {
|
||||
const out = formatLoopError(new Error(`DeepSeek ${status}: `));
|
||||
expect(out).toMatch(new RegExp(`service unavailable \\(${status}\\)`));
|
||||
expect(out).toMatch(/DeepSeek-side problem/);
|
||||
}
|
||||
});
|
||||
|
||||
it("tolerates an empty body on a 5xx — still produces the outage notice", () => {
|
||||
const out = formatLoopError(new Error("DeepSeek 500: "));
|
||||
expect(out).toMatch(/service unavailable \(500\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLoopError — zh-CN runtime switch", () => {
|
||||
afterEach(() => {
|
||||
setLanguageRuntime("EN");
|
||||
});
|
||||
|
||||
it("503 outage notice translates when language is zh-CN", () => {
|
||||
setLanguageRuntime("zh-CN");
|
||||
const out = formatLoopError(new Error("DeepSeek 503: "));
|
||||
expect(out).toContain("服务不可用");
|
||||
expect(out).toContain("503");
|
||||
expect(out).toContain("DeepSeek 服务端问题");
|
||||
expect(out).toContain("status.deepseek.com");
|
||||
});
|
||||
|
||||
it("401 auth error translates when language is zh-CN, preserves the inner DS message", () => {
|
||||
setLanguageRuntime("zh-CN");
|
||||
const out = formatLoopError(
|
||||
new Error('DeepSeek 401: {"error":{"message":"Authentication Fails"}}'),
|
||||
);
|
||||
expect(out).toContain("认证失败");
|
||||
expect(out).toContain("Authentication Fails");
|
||||
expect(out).toContain("reasonix setup");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+989
-2
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { INLINE_PASTE_THRESHOLD, shouldInlinePaste } from "../src/cli/ui/PromptInput.js";
|
||||
import {
|
||||
DEFAULT_PASTE_CHAR_THRESHOLD,
|
||||
DEFAULT_PASTE_HEAD_LINES,
|
||||
@@ -6,6 +7,30 @@ import {
|
||||
formatLongPaste,
|
||||
} from "../src/cli/ui/paste-collapse.js";
|
||||
|
||||
describe("shouldInlinePaste — short single-line pastes render verbatim (#397)", () => {
|
||||
it("a single word inlines", () => {
|
||||
expect(shouldInlinePaste("hello")).toBe(true);
|
||||
});
|
||||
|
||||
it("a number inlines", () => {
|
||||
expect(shouldInlinePaste("42")).toBe(true);
|
||||
});
|
||||
|
||||
it("a sentence-length string still inlines", () => {
|
||||
expect(shouldInlinePaste("the quick brown fox jumps over the lazy dog")).toBe(true);
|
||||
});
|
||||
|
||||
it("anything containing a newline becomes a sentinel chip", () => {
|
||||
expect(shouldInlinePaste("line1\nline2")).toBe(false);
|
||||
expect(shouldInlinePaste("hi\n")).toBe(false);
|
||||
});
|
||||
|
||||
it("a single-line paste past the threshold becomes a sentinel chip", () => {
|
||||
expect(shouldInlinePaste("x".repeat(INLINE_PASTE_THRESHOLD))).toBe(true);
|
||||
expect(shouldInlinePaste("x".repeat(INLINE_PASTE_THRESHOLD + 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLongPaste", () => {
|
||||
it("passes through short input verbatim", () => {
|
||||
const r = formatLongPaste("hello world");
|
||||
|
||||
@@ -724,4 +724,316 @@ describe("dashboard server: modal mirroring (workspace / checkpoint / revision)"
|
||||
});
|
||||
expect(r.status).toBe(503);
|
||||
});
|
||||
|
||||
it("POST /api/modal/resolve dispatches picker pick / delete / install / uninstall", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const base = await boot({ resolvePicker: (r) => calls.push(r) });
|
||||
for (const action of ["pick", "delete", "install", "uninstall"] as const) {
|
||||
const r = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "picker", action, id: `row-${action}` },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
}
|
||||
expect(calls).toEqual([
|
||||
{ action: "pick", id: "row-pick" },
|
||||
{ action: "delete", id: "row-delete" },
|
||||
{ action: "install", id: "row-install" },
|
||||
{ action: "uninstall", id: "row-uninstall" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("POST /api/modal/resolve carries picker rename / new / refine text", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const base = await boot({ resolvePicker: (r) => calls.push(r) });
|
||||
const rename = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "picker", action: "rename", id: "abc", text: "new-name" },
|
||||
});
|
||||
expect(rename.status).toBe(200);
|
||||
const newAction = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "picker", action: "new", text: "scratch" },
|
||||
});
|
||||
expect(newAction.status).toBe(200);
|
||||
const refine = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "picker", action: "refine", query: "search-term" },
|
||||
});
|
||||
expect(refine.status).toBe(200);
|
||||
expect(calls).toEqual([
|
||||
{ action: "rename", id: "abc", text: "new-name" },
|
||||
{ action: "new", text: "scratch" },
|
||||
{ action: "refine", query: "search-term" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("POST /api/modal/resolve accepts picker load-more and cancel without payload", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const base = await boot({ resolvePicker: (r) => calls.push(r) });
|
||||
const more = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "picker", action: "load-more" },
|
||||
});
|
||||
expect(more.status).toBe(200);
|
||||
const cancel = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "picker", action: "cancel" },
|
||||
});
|
||||
expect(cancel.status).toBe(200);
|
||||
expect(calls).toEqual([{ action: "load-more" }, { action: "cancel" }]);
|
||||
});
|
||||
|
||||
it("POST /api/modal/resolve rejects picker pick without id and unknown action", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const base = await boot({ resolvePicker: (r) => calls.push(r) });
|
||||
const noId = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "picker", action: "pick" },
|
||||
});
|
||||
expect(noId.status).toBe(400);
|
||||
const bogus = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "picker", action: "explode" },
|
||||
});
|
||||
expect(bogus.status).toBe(400);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("POST /api/modal/resolve returns 503 when picker resolver is not wired", async () => {
|
||||
const base = await boot({});
|
||||
const r = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "picker", action: "cancel" },
|
||||
});
|
||||
expect(r.status).toBe(503);
|
||||
});
|
||||
|
||||
it("POST /api/modal/resolve dispatches viewer close", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const base = await boot({ resolveViewer: (r) => calls.push(r) });
|
||||
const r = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "viewer", action: "close" },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(calls).toEqual([{ action: "close" }]);
|
||||
});
|
||||
|
||||
it("POST /api/modal/resolve rejects viewer actions other than close", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const base = await boot({ resolveViewer: (r) => calls.push(r) });
|
||||
const r = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "viewer", action: "next" },
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("POST /api/modal/resolve returns 503 when viewer resolver is not wired", async () => {
|
||||
const base = await boot({});
|
||||
const r = await call(`${base}api/modal/resolve`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { kind: "viewer", action: "close" },
|
||||
});
|
||||
expect(r.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard server: D-1 settings + auto-loop surface", () => {
|
||||
let dir: string;
|
||||
let cfgPath: string;
|
||||
let usagePath: string;
|
||||
let handle: DashboardServerHandle | null = null;
|
||||
const TOKEN = "f".repeat(64);
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "reasonix-d1-"));
|
||||
cfgPath = join(dir, "config.json");
|
||||
usagePath = join(dir, "usage.jsonl");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.close();
|
||||
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function boot(extra: Partial<DashboardContext> = {}): Promise<string> {
|
||||
handle = await startDashboardServer(
|
||||
{ mode: "attached", configPath: cfgPath, usageLogPath: usagePath, ...extra },
|
||||
{ token: TOKEN },
|
||||
);
|
||||
return handle.url.split("?")[0]!;
|
||||
}
|
||||
|
||||
it("POST /api/settings routes proNext / budgetUsd / model to live callbacks", async () => {
|
||||
const calls: Record<string, unknown[]> = {
|
||||
proNext: [],
|
||||
budgetUsd: [],
|
||||
model: [],
|
||||
};
|
||||
const base = await boot({
|
||||
setProNextLive: (v) => calls.proNext!.push(v),
|
||||
setBudgetUsdLive: (v) => calls.budgetUsd!.push(v),
|
||||
applyModelLive: (v) => calls.model!.push(v),
|
||||
});
|
||||
const r = await call(`${base}api/settings`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { proNext: true, budgetUsd: 2.5, model: "deepseek-v4-pro" },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.changed).toEqual(expect.arrayContaining(["proNext", "budgetUsd", "model"]));
|
||||
expect(calls.proNext).toEqual([true]);
|
||||
expect(calls.budgetUsd).toEqual([2.5]);
|
||||
expect(calls.model).toEqual(["deepseek-v4-pro"]);
|
||||
});
|
||||
|
||||
it("POST /api/settings rejects non-positive budgetUsd", async () => {
|
||||
const base = await boot({ setBudgetUsdLive: () => undefined });
|
||||
const negative = await call(`${base}api/settings`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { budgetUsd: -1 },
|
||||
});
|
||||
expect(negative.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /api/settings accepts null budgetUsd to clear the cap", async () => {
|
||||
const budgetCalls: unknown[] = [];
|
||||
const base = await boot({ setBudgetUsdLive: (v) => budgetCalls.push(v) });
|
||||
const r = await call(`${base}api/settings`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { budgetUsd: null },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(budgetCalls).toEqual([null]);
|
||||
});
|
||||
|
||||
it("GET /api/loop/status returns null when nothing is running", async () => {
|
||||
const base = await boot({ getLoopRunStatus: () => null });
|
||||
const r = await call(`${base}api/loop/status`, { token: TOKEN });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.status).toBeNull();
|
||||
});
|
||||
|
||||
it("GET /api/loop/status returns the live status snapshot", async () => {
|
||||
const snap = { prompt: "ping", intervalMs: 30_000, iter: 3, nextFireMs: 12_000 };
|
||||
const base = await boot({ getLoopRunStatus: () => snap });
|
||||
const r = await call(`${base}api/loop/status`, { token: TOKEN });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.status).toEqual(snap);
|
||||
});
|
||||
|
||||
it("POST /api/loop/start forwards intervalMs and prompt", async () => {
|
||||
const calls: Array<[number, string]> = [];
|
||||
const base = await boot({ startAutoLoop: (ms, p) => calls.push([ms, p]) });
|
||||
const r = await call(`${base}api/loop/start`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { intervalMs: 30_000, prompt: "check the deploy" },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(calls).toEqual([[30_000, "check the deploy"]]);
|
||||
});
|
||||
|
||||
it("POST /api/loop/start rejects out-of-range interval and missing prompt", async () => {
|
||||
const base = await boot({ startAutoLoop: () => undefined });
|
||||
const tooFast = await call(`${base}api/loop/start`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { intervalMs: 1_000, prompt: "x" },
|
||||
});
|
||||
expect(tooFast.status).toBe(400);
|
||||
const noPrompt = await call(`${base}api/loop/start`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { intervalMs: 30_000, prompt: "" },
|
||||
});
|
||||
expect(noPrompt.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /api/loop/stop calls the stop hook", async () => {
|
||||
let stopped = 0;
|
||||
const base = await boot({
|
||||
stopAutoLoop: () => {
|
||||
stopped++;
|
||||
},
|
||||
});
|
||||
const r = await call(`${base}api/loop/stop`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(stopped).toBe(1);
|
||||
});
|
||||
|
||||
it("GET /api/models returns the cached catalog + pricing + current model", async () => {
|
||||
const base = await boot({ getModels: () => ["deepseek-v4-flash", "deepseek-v4-pro"] });
|
||||
const r = await call(`${base}api/models`, { token: TOKEN });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.models).toEqual(["deepseek-v4-flash", "deepseek-v4-pro"]);
|
||||
expect(r.body.pricing["deepseek-v4-flash"]).toBeDefined();
|
||||
expect(r.body.pricing["deepseek-v4-flash"].output).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("GET /api/models returns null catalog when getModels is not wired", async () => {
|
||||
const base = await boot({});
|
||||
const r = await call(`${base}api/models`, { token: TOKEN });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.models).toBeNull();
|
||||
expect(r.body.pricing).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 503 when loop callbacks are not wired", async () => {
|
||||
const base = await boot({});
|
||||
const status = await call(`${base}api/loop/status`, { token: TOKEN });
|
||||
expect(status.status).toBe(503);
|
||||
const start = await call(`${base}api/loop/start`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
body: { intervalMs: 30_000, prompt: "x" },
|
||||
});
|
||||
expect(start.status).toBe(503);
|
||||
const stop = await call(`${base}api/loop/stop`, {
|
||||
method: "POST",
|
||||
token: TOKEN,
|
||||
tokenInHeader: true,
|
||||
});
|
||||
expect(stop.status).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -345,6 +345,26 @@ describe("handleSlash", () => {
|
||||
expect(r.info).toMatch(/only available inside .reasonix code/);
|
||||
});
|
||||
|
||||
it("/restore with no arg opens the checkpoint picker in code mode", () => {
|
||||
const r = handleSlash("restore", [], makeLoop(), { codeRoot: "/tmp" });
|
||||
expect(r.openCheckpointPicker).toBe(true);
|
||||
expect(r.info).toBeUndefined();
|
||||
});
|
||||
|
||||
it("/restore <name> in code mode still resolves directly (skips picker)", () => {
|
||||
const r = handleSlash("restore", ["nonexistent"], makeLoop(), { codeRoot: "/tmp" });
|
||||
expect(r.openCheckpointPicker).toBeUndefined();
|
||||
expect(r.info).toMatch(/no.*match|not found/i);
|
||||
});
|
||||
|
||||
it("/restore outside code mode is unavailable regardless of args", () => {
|
||||
const noArg = handleSlash("restore", [], makeLoop());
|
||||
expect(noArg.openCheckpointPicker).toBeUndefined();
|
||||
expect(noArg.info).toMatch(/only available inside .reasonix code/);
|
||||
const withArg = handleSlash("restore", ["abc"], makeLoop());
|
||||
expect(withArg.info).toMatch(/only available inside .reasonix code/);
|
||||
});
|
||||
|
||||
it("/undo in code mode invokes the callback", () => {
|
||||
const r = handleSlash("undo", [], makeLoop(), {
|
||||
codeUndo: () => "▸ restored 2 file(s)",
|
||||
|
||||
@@ -82,6 +82,16 @@ describe("TurnTranslator", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards an explicit model id into start{Reasoning,Streaming} so /pro turns get the right badge (#403)", () => {
|
||||
const { log, calls } = makeMockLog();
|
||||
const t = new TurnTranslator(log);
|
||||
t.flushBuffers("first reasoning", "first stream", "deepseek-v4-pro");
|
||||
const startReasoning = calls.find((c) => c.method === "startReasoning");
|
||||
const startStreaming = calls.find((c) => c.method === "startStreaming");
|
||||
expect(startReasoning?.args[0]).toBe("deepseek-v4-pro");
|
||||
expect(startStreaming?.args[0]).toBe("deepseek-v4-pro");
|
||||
});
|
||||
|
||||
it("toolStart + toolEnd pair sends startTool then endTool with elapsedMs", () => {
|
||||
const { log, calls } = makeMockLog();
|
||||
const t = new TurnTranslator(log);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render } from "ink";
|
||||
import React from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CheckpointPicker, type CheckpointPickerOutcome } from "../src/cli/ui/CheckpointPicker.js";
|
||||
import type {
|
||||
PickerBroadcastPorts,
|
||||
PickerSnapshot,
|
||||
} from "../src/cli/ui/dashboard/use-picker-broadcast.js";
|
||||
import type { CheckpointMeta } from "../src/code/checkpoints.js";
|
||||
import type { DashboardEvent, PickerResolution } from "../src/server/context.js";
|
||||
import { makeFakeStdin, makeFakeStdout } from "./helpers/ink-stdio.js";
|
||||
|
||||
function fakeCheckpoint(id: string, name: string, source: CheckpointMeta["source"] = "manual") {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
createdAt: Date.now() - 60_000,
|
||||
source,
|
||||
fileCount: 3,
|
||||
bytes: 4096,
|
||||
} satisfies CheckpointMeta;
|
||||
}
|
||||
|
||||
function makePorts() {
|
||||
const events: DashboardEvent[] = [];
|
||||
const resolverRef = { current: null as ((res: PickerResolution) => void) | null };
|
||||
const snapshotRef = { current: null as PickerSnapshot | null };
|
||||
const ports: PickerBroadcastPorts = {
|
||||
broadcast: (ev) => events.push(ev),
|
||||
resolverRef,
|
||||
snapshotRef,
|
||||
};
|
||||
return { events, ports, fire: (res: PickerResolution) => resolverRef.current?.(res) };
|
||||
}
|
||||
|
||||
function mount(
|
||||
checkpoints: CheckpointMeta[],
|
||||
ports: PickerBroadcastPorts,
|
||||
onChoose: (o: CheckpointPickerOutcome) => void,
|
||||
) {
|
||||
const stdout = makeFakeStdout();
|
||||
return render(
|
||||
React.createElement(CheckpointPicker, {
|
||||
checkpoints,
|
||||
workspace: "/repo",
|
||||
onChoose,
|
||||
pickerPorts: ports,
|
||||
}),
|
||||
{ stdout: stdout as never, stdin: makeFakeStdin() as never },
|
||||
);
|
||||
}
|
||||
|
||||
describe("CheckpointPicker — dashboard broadcast", () => {
|
||||
it("emits modal-up with checkpoints mapped into picker items", () => {
|
||||
const { events, ports } = makePorts();
|
||||
const { unmount } = mount(
|
||||
[
|
||||
fakeCheckpoint("abcdef0123", "before-refactor"),
|
||||
fakeCheckpoint("123456789a", "auto-snap", "auto"),
|
||||
],
|
||||
ports,
|
||||
() => undefined,
|
||||
);
|
||||
const up = events.find((e) => e.kind === "modal-up");
|
||||
if (!up || up.kind !== "modal-up" || up.modal.kind !== "picker") {
|
||||
throw new Error("expected picker modal-up");
|
||||
}
|
||||
expect(up.modal.pickerKind).toBe("checkpoints");
|
||||
expect(up.modal.items.map((i) => i.id)).toEqual(["abcdef0123", "123456789a"]);
|
||||
expect(up.modal.items[0]!.title).toBe("before-refactor");
|
||||
expect(up.modal.items[1]!.title).toBe("auto-snap (auto)");
|
||||
expect(up.modal.items[0]!.badge).toBe("abcdef0");
|
||||
expect(up.modal.actions).toEqual(["pick", "delete", "cancel"]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("translates web pick / delete / cancel into matching outcomes", () => {
|
||||
const outcomes: CheckpointPickerOutcome[] = [];
|
||||
const { ports, fire } = makePorts();
|
||||
const { unmount } = mount(
|
||||
[fakeCheckpoint("idA", "alpha"), fakeCheckpoint("idB", "beta")],
|
||||
ports,
|
||||
(o) => outcomes.push(o),
|
||||
);
|
||||
fire({ action: "pick", id: "idA" });
|
||||
fire({ action: "delete", id: "idB" });
|
||||
fire({ action: "cancel" });
|
||||
expect(outcomes).toEqual([
|
||||
{ kind: "restore", id: "idA" },
|
||||
{ kind: "delete", id: "idB" },
|
||||
{ kind: "quit" },
|
||||
]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does not broadcast when pickerPorts is omitted", () => {
|
||||
const stdout = makeFakeStdout();
|
||||
const { unmount } = render(
|
||||
React.createElement(CheckpointPicker, {
|
||||
checkpoints: [fakeCheckpoint("x", "y")],
|
||||
workspace: "/repo",
|
||||
onChoose: () => undefined,
|
||||
}),
|
||||
{ stdout: stdout as never, stdin: makeFakeStdin() as never },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMarketplacePickerSnapshot } from "../src/cli/ui/McpMarketplace.js";
|
||||
import { specStringFor } from "../src/mcp/registry-fetch.js";
|
||||
import type { RegistryEntry, RegistryInstall } from "../src/mcp/registry-types.js";
|
||||
|
||||
const officialInstall: RegistryInstall = {
|
||||
runtime: "npm",
|
||||
packageId: "@official/mcp-time",
|
||||
transport: "stdio",
|
||||
};
|
||||
|
||||
const officialEntry: RegistryEntry = {
|
||||
name: "@official/mcp-time",
|
||||
title: "Time",
|
||||
description: "Returns the current time.",
|
||||
source: "official",
|
||||
install: officialInstall,
|
||||
popularity: 1234,
|
||||
};
|
||||
|
||||
const smitheryEntry: RegistryEntry = {
|
||||
name: "vendor/searchy",
|
||||
title: "Searchy",
|
||||
description: "Searches the web.",
|
||||
source: "smithery",
|
||||
};
|
||||
|
||||
const localEntry: RegistryEntry = {
|
||||
name: "local/scratch",
|
||||
title: "Scratch",
|
||||
description: "Local-only test entry.",
|
||||
source: "local",
|
||||
install: { runtime: "npm", packageId: "scratch", transport: "stdio" },
|
||||
};
|
||||
|
||||
describe("buildMarketplacePickerSnapshot", () => {
|
||||
it("maps entries into picker items with source-tagged badges", () => {
|
||||
const snap = buildMarketplacePickerSnapshot({
|
||||
filtered: [officialEntry, smitheryEntry, localEntry],
|
||||
installedSpecs: [],
|
||||
query: "",
|
||||
status: "ready",
|
||||
hasMore: false,
|
||||
});
|
||||
expect(snap.pickerKind).toBe("mcp-marketplace");
|
||||
expect(snap.items.map((i) => i.id)).toEqual([
|
||||
"@official/mcp-time",
|
||||
"vendor/searchy",
|
||||
"local/scratch",
|
||||
]);
|
||||
expect(snap.items.map((i) => i.badge)).toEqual(["official", "smithery", "local"]);
|
||||
expect(snap.items[0]!.meta).toBe("★ 1,234");
|
||||
expect(snap.items[1]!.meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flips badge to `installed` when entry's spec is in installedSpecs", () => {
|
||||
const spec = specStringFor(officialEntry.name, officialInstall);
|
||||
const snap = buildMarketplacePickerSnapshot({
|
||||
filtered: [officialEntry, smitheryEntry],
|
||||
installedSpecs: [spec],
|
||||
query: "",
|
||||
status: "ready",
|
||||
hasMore: false,
|
||||
});
|
||||
expect(snap.items[0]!.badge).toBe("installed");
|
||||
expect(snap.items[1]!.badge).toBe("smithery");
|
||||
});
|
||||
|
||||
it("carries query, hasMore, and the full picker action set", () => {
|
||||
const snap = buildMarketplacePickerSnapshot({
|
||||
filtered: [officialEntry],
|
||||
installedSpecs: [],
|
||||
query: "time",
|
||||
status: "1 match",
|
||||
hasMore: true,
|
||||
});
|
||||
expect(snap.query).toBe("time");
|
||||
expect(snap.title).toBe("MCP marketplace · 1 match");
|
||||
expect(snap.hasMore).toBe(true);
|
||||
expect(snap.actions).toEqual(["install", "uninstall", "refine", "load-more", "cancel"]);
|
||||
});
|
||||
|
||||
it("truncates long descriptions for the subtitle", () => {
|
||||
const long = "x".repeat(300);
|
||||
const snap = buildMarketplacePickerSnapshot({
|
||||
filtered: [{ ...officialEntry, description: long }],
|
||||
installedSpecs: [],
|
||||
query: "",
|
||||
status: "",
|
||||
hasMore: false,
|
||||
});
|
||||
expect(snap.items[0]!.subtitle).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,20 @@ describe("ui reducer", () => {
|
||||
expect(card.model).toBe("deepseek-chat");
|
||||
});
|
||||
|
||||
it("reasoning.start carrying an explicit model overrides the session snapshot (#403 /pro armed turn)", () => {
|
||||
const s = run([{ type: "reasoning.start", id: "r1", model: "deepseek-v4-pro" }]);
|
||||
const card = s.cards[0] as ReasoningCard;
|
||||
expect(card.model).toBe("deepseek-v4-pro");
|
||||
expect(s.session.model).toBe("deepseek-chat");
|
||||
});
|
||||
|
||||
it("streaming.start carrying an explicit model overrides the session snapshot (#403 /pro armed turn)", () => {
|
||||
const s = run([{ type: "streaming.start", id: "s1", model: "deepseek-v4-pro" }]);
|
||||
const card = s.cards[0] as StreamingCard;
|
||||
expect(card.model).toBe("deepseek-v4-pro");
|
||||
expect(s.session.model).toBe("deepseek-chat");
|
||||
});
|
||||
|
||||
it("session.model.change updates the active model so the next card snapshots it (#372)", () => {
|
||||
const s = run([
|
||||
{ type: "session.model.change", model: "deepseek-v4-pro" },
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { render } from "ink";
|
||||
import React from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SessionPicker, type SessionPickerOutcome } from "../src/cli/ui/SessionPicker.js";
|
||||
import type {
|
||||
PickerBroadcastPorts,
|
||||
PickerSnapshot,
|
||||
} from "../src/cli/ui/dashboard/use-picker-broadcast.js";
|
||||
import type { SessionInfo } from "../src/memory/session.js";
|
||||
import type { DashboardEvent, PickerResolution } from "../src/server/context.js";
|
||||
import { makeFakeStdin, makeFakeStdout } from "./helpers/ink-stdio.js";
|
||||
|
||||
function fakeSession(name: string, branch = "main"): SessionInfo {
|
||||
return {
|
||||
name,
|
||||
path: `/tmp/${name}.jsonl`,
|
||||
size: 100,
|
||||
messageCount: 4,
|
||||
mtime: new Date("2026-05-06T00:00:00Z"),
|
||||
meta: { branch, summary: `${name} session`, totalCostUsd: 0.1, turnCount: 2, workspace: "/r" },
|
||||
};
|
||||
}
|
||||
|
||||
function makePorts(): {
|
||||
ports: PickerBroadcastPorts;
|
||||
events: DashboardEvent[];
|
||||
fire: (res: PickerResolution) => void;
|
||||
} {
|
||||
const events: DashboardEvent[] = [];
|
||||
const resolverRef = { current: null as ((res: PickerResolution) => void) | null };
|
||||
const snapshotRef = { current: null as PickerSnapshot | null };
|
||||
return {
|
||||
events,
|
||||
ports: {
|
||||
broadcast: (ev) => {
|
||||
events.push(ev);
|
||||
},
|
||||
resolverRef,
|
||||
snapshotRef,
|
||||
},
|
||||
fire: (res) => resolverRef.current?.(res),
|
||||
};
|
||||
}
|
||||
|
||||
function mount(
|
||||
sessions: SessionInfo[],
|
||||
ports: PickerBroadcastPorts,
|
||||
onChoose: (o: SessionPickerOutcome) => void,
|
||||
) {
|
||||
const stdout = makeFakeStdout();
|
||||
return render(
|
||||
React.createElement(SessionPicker, {
|
||||
sessions,
|
||||
workspace: "/repo",
|
||||
onChoose,
|
||||
pickerPorts: ports,
|
||||
}),
|
||||
{ stdout: stdout as never, stdin: makeFakeStdin() as never },
|
||||
);
|
||||
}
|
||||
|
||||
describe("SessionPicker — dashboard broadcast", () => {
|
||||
it("emits modal-up with sessions mapped into picker items", () => {
|
||||
const { events, ports } = makePorts();
|
||||
const { unmount } = mount(
|
||||
[fakeSession("alpha"), fakeSession("beta", "feature/x")],
|
||||
ports,
|
||||
() => undefined,
|
||||
);
|
||||
const up = events.find((e) => e.kind === "modal-up");
|
||||
expect(up).toBeTruthy();
|
||||
if (!up || up.kind !== "modal-up" || up.modal.kind !== "picker") {
|
||||
throw new Error("expected picker modal-up");
|
||||
}
|
||||
expect(up.modal.pickerKind).toBe("sessions");
|
||||
expect(up.modal.items.map((i) => i.id)).toEqual(["alpha", "beta"]);
|
||||
expect(up.modal.items[1]!.badge).toBe("feature/x");
|
||||
expect(up.modal.actions).toEqual(["pick", "delete", "rename", "new", "cancel"]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("emits modal-down on unmount", () => {
|
||||
const { events, ports } = makePorts();
|
||||
const { unmount } = mount([fakeSession("a")], ports, () => undefined);
|
||||
unmount();
|
||||
const down = events.find((e) => e.kind === "modal-down");
|
||||
expect(down).toBeTruthy();
|
||||
if (!down || down.kind !== "modal-down") throw new Error();
|
||||
expect(down.modalKind).toBe("picker");
|
||||
});
|
||||
|
||||
it("translates web pick into onChoose open", () => {
|
||||
const outcomes: SessionPickerOutcome[] = [];
|
||||
const { ports, fire } = makePorts();
|
||||
const { unmount } = mount([fakeSession("alpha")], ports, (o) => outcomes.push(o));
|
||||
fire({ action: "pick", id: "alpha" });
|
||||
expect(outcomes).toEqual([{ kind: "open", name: "alpha" }]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("translates web delete / rename / new / cancel into matching outcomes", () => {
|
||||
const outcomes: SessionPickerOutcome[] = [];
|
||||
const { ports, fire } = makePorts();
|
||||
const { unmount } = mount([fakeSession("alpha"), fakeSession("beta")], ports, (o) =>
|
||||
outcomes.push(o),
|
||||
);
|
||||
fire({ action: "delete", id: "alpha" });
|
||||
fire({ action: "rename", id: "beta", text: "beta-renamed" });
|
||||
fire({ action: "new" });
|
||||
fire({ action: "cancel" });
|
||||
expect(outcomes).toEqual([
|
||||
{ kind: "delete", name: "alpha" },
|
||||
{ kind: "rename", name: "beta", newName: "beta-renamed" },
|
||||
{ kind: "new" },
|
||||
{ kind: "quit" },
|
||||
]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does not broadcast when pickerPorts is omitted", () => {
|
||||
const stdout = makeFakeStdout();
|
||||
const { unmount } = render(
|
||||
React.createElement(SessionPicker, {
|
||||
sessions: [fakeSession("a")],
|
||||
workspace: "/repo",
|
||||
onChoose: () => undefined,
|
||||
}),
|
||||
{ stdout: stdout as never, stdin: makeFakeStdin() as never },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* StatusRow wallet rendering - verifies the currency symbol matches the
|
||||
* balance currency, not hardcoded ¥.
|
||||
*
|
||||
* These tests import the REAL StatusRow component and render it through
|
||||
* Ink with a mock AgentStore. They FAIL today because StatusRow:61
|
||||
* hardcodes ¥ and the state has no balanceCurrency field.
|
||||
* StatusRow turn-cost rendering — wallet + session-cost segments live in
|
||||
* StatsPanel / UsageCard now (covered by their own tests). This file only
|
||||
* asserts the turn-cost + cache cells StatusRow still renders.
|
||||
*/
|
||||
import { render } from "ink";
|
||||
import React, { useEffect } from "react";
|
||||
@@ -21,7 +18,6 @@ const SESSION: SessionInfo = {
|
||||
model: "deepseek-chat",
|
||||
};
|
||||
|
||||
/** Dispatches arbitrary events on mount into the store created by AgentStoreProvider. */
|
||||
function EventInjector({
|
||||
events,
|
||||
children,
|
||||
@@ -37,7 +33,6 @@ function EventInjector({
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
}
|
||||
|
||||
/** Convenience: inject a single session.update with status overrides. */
|
||||
function StateInjector({
|
||||
overrides,
|
||||
children,
|
||||
@@ -51,7 +46,6 @@ function StateInjector({
|
||||
});
|
||||
}
|
||||
|
||||
/** Render <StatusRow /> through Ink with a fake stdout, return collected text. */
|
||||
async function renderStatusRow(overrides: Partial<AgentState["status"]>): Promise<string> {
|
||||
const stdout = makeFakeStdout();
|
||||
const { unmount } = render(
|
||||
@@ -62,139 +56,47 @@ async function renderStatusRow(overrides: Partial<AgentState["status"]>): Promis
|
||||
</AgentStoreProvider>,
|
||||
{ stdout: stdout as never, stdin: makeFakeStdin() as never },
|
||||
);
|
||||
// Let the StateInjector effect fire and StatusRow re-render.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
unmount();
|
||||
return stdout.text();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("StatusRow - wallet currency symbol", () => {
|
||||
it("shows $ for USD balance", async () => {
|
||||
const text = await renderStatusRow({ balance: 0.91, balanceCurrency: "USD" } as any);
|
||||
expect(text).toContain("$0.91");
|
||||
});
|
||||
|
||||
it("shows ¥ for CNY balance", async () => {
|
||||
const text = await renderStatusRow({ balance: 6.55, balanceCurrency: "CNY" } as any);
|
||||
expect(text).toContain("¥6.55");
|
||||
});
|
||||
|
||||
it("shows no wallet when balance is undefined", async () => {
|
||||
const text = await renderStatusRow({ balance: undefined } as any);
|
||||
expect(text).not.toContain("wallet");
|
||||
});
|
||||
|
||||
it("uses correct color for USD $0.91 (~¥6.55 -> warn, not err)", async () => {
|
||||
// $0.91 * 7.2 = ¥6.55 -> warn range (yellow), not err (red).
|
||||
// The err color is #ff8b81, warn is #f0b07d.
|
||||
const text = await renderStatusRow({ balance: 0.91, balanceCurrency: "USD" } as any);
|
||||
expect(text).toContain("$0.91");
|
||||
// After the fix, the color should be warn (yellow) not err (red).
|
||||
// For now, this test just confirms the symbol is correct.
|
||||
});
|
||||
|
||||
// ---- Turn/session costs must follow wallet currency ----
|
||||
// When the wallet is in USD, costs should show in USD ($).
|
||||
// When the wallet is in CNY, costs should show in CNY (¥).
|
||||
// When no wallet is loaded, default to CNY (DeepSeek native pricing).
|
||||
|
||||
it("USD wallet: turn/session costs show $, not ¥", async () => {
|
||||
describe("StatusRow — turn cost currency", () => {
|
||||
it("USD wallet: turn cost shows $", async () => {
|
||||
const text = await renderStatusRow({
|
||||
cost: 0.0308,
|
||||
sessionCost: 0.064,
|
||||
balance: 0.71,
|
||||
balanceCurrency: "USD",
|
||||
} as any);
|
||||
// Cost in USD, no conversion: $0.0308 turn, $0.064 session
|
||||
expect(text).toContain("$0.0308 turn");
|
||||
expect(text).toContain("$0.064 session");
|
||||
expect(text).toContain("wallet $0.71");
|
||||
expect(text).not.toContain(" session ");
|
||||
expect(text).not.toContain("wallet ");
|
||||
});
|
||||
|
||||
it("CNY wallet: turn/session costs show ¥ (converted from USD)", async () => {
|
||||
it("CNY wallet: turn cost shows ¥ (USD→CNY)", async () => {
|
||||
const text = await renderStatusRow({
|
||||
cost: 0.0308,
|
||||
sessionCost: 0.064,
|
||||
balance: 6.55,
|
||||
balanceCurrency: "CNY",
|
||||
} as any);
|
||||
// 0.0308 USD * 7.2 = 0.2218 CNY → "¥0.2218 turn"
|
||||
expect(text).toContain("¥0.2218 turn");
|
||||
// 0.064 USD * 7.2 = 0.461 CNY → "¥0.461 session"
|
||||
expect(text).toContain("¥0.461 session");
|
||||
expect(text).toContain("wallet ¥6.55");
|
||||
expect(text).not.toContain(" session ");
|
||||
expect(text).not.toContain("wallet ");
|
||||
});
|
||||
|
||||
it("no wallet info: costs default to CNY (backward compat)", async () => {
|
||||
const text = await renderStatusRow({
|
||||
cost: 0.0308,
|
||||
sessionCost: 0.064,
|
||||
balance: undefined,
|
||||
} as any);
|
||||
// When balanceCurrency is undefined, fall back to CNY display.
|
||||
it("no wallet info: turn cost defaults to ¥", async () => {
|
||||
const text = await renderStatusRow({ cost: 0.0308, balance: undefined } as any);
|
||||
expect(text).toContain("¥0.2218 turn");
|
||||
expect(text).toContain("¥0.461 session");
|
||||
expect(text).not.toContain("wallet");
|
||||
expect(text).not.toContain("wallet ");
|
||||
});
|
||||
|
||||
// ---- Full turn flow (pricing -> turn.end -> session.update -> display) ----
|
||||
|
||||
it("full USD flow: turn.end + session.update renders all $ symbols", async () => {
|
||||
const stdout = makeFakeStdout();
|
||||
const { unmount } = render(
|
||||
<AgentStoreProvider session={SESSION}>
|
||||
<EventInjector
|
||||
events={[
|
||||
{
|
||||
type: "turn.end",
|
||||
usage: { prompt: 1000, reason: 0, output: 200, cacheHit: 0.8, cost: 0.00015 },
|
||||
},
|
||||
{ type: "session.update", patch: { balance: 0.71, balanceCurrency: "USD" } },
|
||||
]}
|
||||
>
|
||||
<StatusRow />
|
||||
</EventInjector>
|
||||
</AgentStoreProvider>,
|
||||
{ stdout: stdout as never, stdin: makeFakeStdin() as never },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
unmount();
|
||||
const text = stdout.text();
|
||||
expect(text).toContain("$0.0001 turn");
|
||||
expect(text).toContain("$0.000 session");
|
||||
expect(text).toContain("wallet $0.71");
|
||||
it("turn cost hidden when zero", async () => {
|
||||
const text = await renderStatusRow({ cost: 0 } as any);
|
||||
expect(text).not.toContain("turn");
|
||||
});
|
||||
|
||||
it("full CNY flow: turn.end + session.update renders all ¥ symbols", async () => {
|
||||
const stdout = makeFakeStdout();
|
||||
const { unmount } = render(
|
||||
<AgentStoreProvider session={SESSION}>
|
||||
<EventInjector
|
||||
events={[
|
||||
{
|
||||
type: "turn.end",
|
||||
usage: { prompt: 1000, reason: 0, output: 200, cacheHit: 0.8, cost: 0.00015 },
|
||||
},
|
||||
{ type: "session.update", patch: { balance: 6.55, balanceCurrency: "CNY" } },
|
||||
]}
|
||||
>
|
||||
<StatusRow />
|
||||
</EventInjector>
|
||||
</AgentStoreProvider>,
|
||||
{ stdout: stdout as never, stdin: makeFakeStdin() as never },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
unmount();
|
||||
const text = stdout.text();
|
||||
// 0.00015 USD * 7.2 = 0.0011 CNY → "¥0.0011 turn"
|
||||
expect(text).toContain("¥0.0011 turn");
|
||||
// 0.00015 USD * 7.2 = 0.001 CNY (3 fraction digits)
|
||||
expect(text).toContain("¥0.001 session");
|
||||
// Wallet in ¥
|
||||
expect(text).toContain("wallet ¥6.55");
|
||||
it("cache % always rendered", async () => {
|
||||
const text = await renderStatusRow({ cost: 0, cacheHit: 0.873 } as any);
|
||||
expect(text).toContain("cache 87%");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
/** Wizard data-transform — buildSpec → parseMcpSpec round-trip; bugs here = silent config-save failures. */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSpec } from "../src/cli/ui/Wizard.js";
|
||||
import { render } from "ink-testing-library";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Wizard, buildSpec } from "../src/cli/ui/Wizard.js";
|
||||
import { setLanguageRuntime } from "../src/i18n/index.js";
|
||||
import { parseMcpSpec } from "../src/mcp/spec.js";
|
||||
|
||||
describe("Wizard.buildSpec → parseMcpSpec round-trip", () => {
|
||||
@@ -37,3 +40,28 @@ describe("Wizard.buildSpec → parseMcpSpec round-trip", () => {
|
||||
expect(buildSpec("not-in-catalog", {})).toBe("not-in-catalog");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Wizard — first-launch language picker", () => {
|
||||
afterEach(() => {
|
||||
setLanguageRuntime("EN");
|
||||
});
|
||||
|
||||
it("shows the language step first, with both supported languages", () => {
|
||||
const { lastFrame, unmount } = render(<Wizard onComplete={() => {}} />);
|
||||
const out = lastFrame() ?? "";
|
||||
expect(out).toContain("Choose your language");
|
||||
expect(out).toContain("English");
|
||||
expect(out).toContain("简体中文");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("shows the title in zh-CN when runtime language is set to zh-CN", () => {
|
||||
setLanguageRuntime("zh-CN");
|
||||
const { lastFrame, unmount } = render(<Wizard onComplete={() => {}} />);
|
||||
const out = lastFrame() ?? "";
|
||||
expect(out).toContain("选择语言");
|
||||
expect(out).toContain("English");
|
||||
expect(out).toContain("简体中文");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user