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 | ||
|
|
f422a94dfa | ||
|
|
ee5961fe70 | ||
|
|
15b4060e68 | ||
|
|
9ec5b99e07 | ||
|
|
e93c4ffde3 | ||
|
|
0e4cba3670 | ||
|
|
d0b251d43a | ||
|
|
daebf3d571 | ||
|
|
28119cad01 | ||
|
|
3c24c0f1e1 | ||
|
|
7edcb07465 | ||
|
|
04b12e9f2d | ||
|
|
b07b86ee2f | ||
|
|
7ba3d086ff | ||
|
|
1a7d74f3f0 | ||
|
|
0f7dcd026a | ||
|
|
12998d246d | ||
|
|
43d437fd38 | ||
|
|
62813e3d6d | ||
|
|
a29a0ac157 | ||
|
|
ccdbfb3a28 | ||
|
|
1805b0c647 | ||
|
|
4032930fed |
No files matched your search
@@ -4,6 +4,10 @@ about: Something is broken
|
||||
labels: bug
|
||||
---
|
||||
|
||||
> **Screen flicker, garbled output, leftover artifacts, cursor jumping?**
|
||||
> Use the **Display / rendering issue** template instead — it asks for the
|
||||
> terminal-specific info we need to diagnose those.
|
||||
|
||||
**What happened**
|
||||
A clear and concise description.
|
||||
|
||||
@@ -14,10 +18,13 @@ What you expected to happen.
|
||||
Steps or minimal code that reproduces it.
|
||||
|
||||
**Environment**
|
||||
- Reasonix version:
|
||||
- Node version:
|
||||
- OS:
|
||||
- DeepSeek model (e.g. `deepseek-chat`, `deepseek-reasoner`):
|
||||
- Reasonix version (`reasonix --version`):
|
||||
- Node version (`node --version`):
|
||||
- OS (Windows 11 / macOS 14 / Ubuntu 24.04 / …):
|
||||
- Shell (bash, zsh, fish, PowerShell 7, PowerShell 5.1, cmd, …):
|
||||
- Terminal app (Windows Terminal, iTerm2, Alacritty, kitty, WezTerm, **VSCode integrated**, **Cursor integrated**, Hyper, …):
|
||||
- DeepSeek model (e.g. `deepseek-v4-flash`, `deepseek-v4-pro`):
|
||||
|
||||
**Logs / transcript**
|
||||
If using the CLI, attach the relevant chunk of `--transcript`.
|
||||
If using the CLI, attach the relevant chunk of `--transcript`, or run
|
||||
`reasonix doctor` and paste the output.
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: Display / rendering issue
|
||||
about: Screen flicker, garbled output, leftover artifacts, cursor jumping
|
||||
labels: bug, rendering
|
||||
---
|
||||
|
||||
> Display problems almost always come from the **terminal emulator**, not
|
||||
> the shell. Please fill the terminal section carefully — `bash vs PowerShell`
|
||||
> tells us very little; `VSCode integrated terminal vs Windows Terminal`
|
||||
> tells us everything.
|
||||
|
||||
**Symptom** (tick all that apply)
|
||||
- [ ] Whole screen flickers / flashes during streaming response
|
||||
- [ ] Lines tear or only half-redraw
|
||||
- [ ] Stale output left behind after a frame updates
|
||||
- [ ] Cursor jumps to wrong column or vanishes
|
||||
- [ ] Mojibake / wrong-width characters (e.g. `□`, half-width emoji)
|
||||
- [ ] Other (describe below)
|
||||
|
||||
**When it happens**
|
||||
- [ ] During assistant streaming (token-by-token output)
|
||||
- [ ] When tool cards expand / collapse
|
||||
- [ ] During scroll-up / scrollback
|
||||
- [ ] On terminal resize
|
||||
- [ ] On launch / on quit
|
||||
- [ ] Other (describe below)
|
||||
|
||||
**Terminal — the important part**
|
||||
|
||||
Where exactly are you running `reasonix`?
|
||||
|
||||
- [ ] **VSCode** integrated terminal — VSCode version: `?`
|
||||
- [ ] **Cursor** integrated terminal — Cursor version: `?`
|
||||
- [ ] **Windows Terminal** — version: `?`
|
||||
- [ ] **cmd.exe** (legacy console host)
|
||||
- [ ] **PowerShell ISE** (note: ISE doesn't support ANSI — most things will look broken)
|
||||
- [ ] **iTerm2** / **Terminal.app** / **Alacritty** / **kitty** / **WezTerm** / **Hyper**
|
||||
- [ ] tmux / screen / mosh — and inside which outer terminal? `?`
|
||||
- [ ] Other:
|
||||
|
||||
> 💡 **How to find your VSCode / Cursor version**
|
||||
> `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS).
|
||||
> Paste the whole panel — version + commit + Electron + xterm.js if shown.
|
||||
|
||||
**Diagnostic dump — copy/paste output**
|
||||
|
||||
Run **one** of the snippets below in the same terminal where you saw the
|
||||
issue, and paste the output here:
|
||||
|
||||
<details><summary>PowerShell (Windows)</summary>
|
||||
|
||||
```powershell
|
||||
reasonix --version; node --version
|
||||
$PSVersionTable.PSVersion.ToString()
|
||||
[System.Environment]::OSVersion.VersionString
|
||||
"TERM=$env:TERM"
|
||||
"TERM_PROGRAM=$env:TERM_PROGRAM"
|
||||
"TERM_PROGRAM_VERSION=$env:TERM_PROGRAM_VERSION"
|
||||
"COLORTERM=$env:COLORTERM"
|
||||
"WT_SESSION=$env:WT_SESSION"
|
||||
"VSCODE_INJECTION=$env:VSCODE_INJECTION"
|
||||
"WSL_DISTRO_NAME=$env:WSL_DISTRO_NAME"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>bash / zsh (macOS / Linux / WSL / Git Bash)</summary>
|
||||
|
||||
```bash
|
||||
reasonix --version; node --version
|
||||
uname -a
|
||||
echo "TERM=$TERM"
|
||||
echo "TERM_PROGRAM=$TERM_PROGRAM"
|
||||
echo "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION"
|
||||
echo "COLORTERM=$COLORTERM"
|
||||
echo "WT_SESSION=$WT_SESSION"
|
||||
echo "VSCODE_INJECTION=$VSCODE_INJECTION"
|
||||
echo "WSL_DISTRO_NAME=$WSL_DISTRO_NAME"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
```
|
||||
<paste output here>
|
||||
```
|
||||
|
||||
**VSCode / Cursor users only — terminal settings**
|
||||
|
||||
Open Settings (`Ctrl+,`), search `terminal.integrated.gpuAcceleration`,
|
||||
report current value: `auto` / `on` / `canvas` / `off` — `?`
|
||||
|
||||
Already tried any of:
|
||||
- [ ] Switching `gpuAcceleration` to a different value
|
||||
- [ ] Detaching the terminal (drag tab into its own window)
|
||||
- [ ] Running the same command in a non-VSCode terminal — did it still flicker? `yes / no`
|
||||
|
||||
**Reproduction**
|
||||
|
||||
Steps that reliably trigger it (commands run, files edited, was a tool
|
||||
streaming a long response, was the window being resized, …):
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Screen recording (strongly preferred)**
|
||||
|
||||
A 5–10s GIF or MP4 is worth 1000 words for rendering bugs. Drop it in
|
||||
this comment box — GitHub uploads attachments inline.
|
||||
@@ -33,4 +33,6 @@ sessions/
|
||||
.reasonix/semantic-skip
|
||||
# Scratch entry regenerated each time scripts/bundle-codemirror.mjs runs.
|
||||
scripts/.cm-entry.mjs
|
||||
# Personal bun lockfile — project uses npm officially.
|
||||
bun.lock
|
||||
|
||||
+365
@@ -3,6 +3,371 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.31.0] — 2026-05-08
|
||||
|
||||
**Headline:** a Mac user reported a DeepSeek 503 day where Reasonix
|
||||
showed a wall of raw `DeepSeek 503: <html>...` and they couldn't tell
|
||||
if our agent had crashed or the upstream API was down. Two threads of
|
||||
work fell out of that single bug: a friendly outage notice with a
|
||||
1.5s reachability probe to `/user/balance` (so we can say "DS main
|
||||
API answered, but /chat/completions is failing — their problem") and
|
||||
a full sweep of every hardcoded English string a Chinese user could
|
||||
hit. ~150 strings across 8 files moved into the `loop.*` / `errors.*`
|
||||
/ `app.*` / `hooks.*` / `summary.*` / `wizard.*` namespaces with
|
||||
zh-CN translations. The setup wizard now opens with a language picker
|
||||
defaulting to `detectSystemLanguage()` — for the case where
|
||||
`Intl.DateTimeFormat().resolvedOptions().locale` returns the wrong
|
||||
locale and a user shouldn't have to discover `/language` after
|
||||
finishing setup in English.
|
||||
|
||||
The other half of the release is dashboard parity work — picker
|
||||
modals (sessions / checkpoint / MCP marketplace), viewer modal for
|
||||
`/replay`, plus cockpit / budget gauge / model picker / loop control
|
||||
panel / `/pro` one-shot — closing buckets B-E of the #369 web-parity
|
||||
tracker.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(loop): friendly DeepSeek 5xx error with reachability probe.
|
||||
When the chat endpoint returns 5xx (after retry.ts has already
|
||||
retried 4× with backoff), `formatLoopError` now spawns a 1.5s
|
||||
`/user/balance` probe and renders one of three messages: no probe
|
||||
(generic outage notice), reachable (main API up but /chat dying),
|
||||
unreachable (DS or your network is down). All three say "this is
|
||||
a DeepSeek-side problem, not Reasonix" and link
|
||||
https://status.deepseek.com. Removes the misleading file header in
|
||||
`loop/errors.ts` that claimed retry.ts swallowed all 5xx — it
|
||||
doesn't, and never did. (#440)
|
||||
- feat(wizard): first-launch language picker + full i18n. New
|
||||
`language` step before `apiKey`, cursor defaults to
|
||||
`detectSystemLanguage()` marked `(detected)`. Selection saves
|
||||
immediately so all later wizard screens render in the chosen
|
||||
language. Re-running `reasonix setup` opens at the same step with
|
||||
the cursor on the saved language so Enter is a no-op. The wizard's
|
||||
~30 hardcoded strings (welcome, prompts, validation errors, MCP
|
||||
catalog hints, review labels, save errors, saved screen) all moved
|
||||
to a new `wizard.*` namespace with zh-CN. (#442)
|
||||
- feat(dashboard): picker modal protocol for web parity. New
|
||||
`picker` modal kind drives sessions, checkpoint, and MCP
|
||||
marketplace pickers from the same protocol. Closes the gap where
|
||||
TUI-only modals stayed inaccessible from `/dashboard`. (#417,
|
||||
#418, #419, #420)
|
||||
- feat(dashboard): viewer modal kind for `/replay`. Loads an archived
|
||||
plan into a read-only time-travel snapshot, mirrors the TUI replay
|
||||
experience. (#421)
|
||||
- feat(dashboard): cockpit tile + budget gauge + 14-day cost trend.
|
||||
At-a-glance current-session telemetry on the overview panel. (#431)
|
||||
- feat(dashboard): editable model picker in settings + `/pro`
|
||||
one-shot panel + loop control (start / stop / countdown). The
|
||||
settings tab is now the single place to flip model preset, arm
|
||||
`/pro` for the next turn, or start an autonomous loop without
|
||||
switching to the TUI. (#430, #432, #433)
|
||||
- feat(dashboard): server surface for `/pro` / `/budget` / `/model`
|
||||
/ `/loop`. POST endpoints under `/api/cockpit/*` carry the
|
||||
mutations the panels above need. (#429)
|
||||
|
||||
**i18n sweep:**
|
||||
|
||||
- i18n(loop/errors): localize DeepSeek error messages — context
|
||||
overflow, 401/402/422/400, 5xx (with the new reachability probe
|
||||
variants), reason prefixes for budget/aborted/context-guard/stuck.
|
||||
20 keys + zh-CN. (#444)
|
||||
- i18n(loop): 14 user-facing yields in `step()` — budget exhausted /
|
||||
80% warning, /pro armed, aborted-at-iter, tool-budget warning,
|
||||
preflight fold/no-fold, flash + auto escalation, storm-broken,
|
||||
history compaction (regular + aggressive), forcing-summary. The
|
||||
`loop.*` namespace had 7 dead keys defined but never wired —
|
||||
removed and replaced with 20 that match the actual yield shapes.
|
||||
(#445)
|
||||
- i18n(hooks/summary): hook outcome formatter (`hook PreToolUse/Bash
|
||||
\`cmd\` block (output truncated at 256KB)`) and the force-summary
|
||||
status / hallucinated-fallback / failed-fallback strings now go
|
||||
through `t()`. New `hooks.*` and `summary.*` namespaces. (#446)
|
||||
- i18n(app): ~26 hardcoded strings in `App.tsx` plus seven existing
|
||||
`ui.*` keys that had been declared but never called (same dead-key
|
||||
pattern as `loop.*`). New `app.*` namespace covers walk modal,
|
||||
edit-mode cycle (review/auto/yolo), edit gate, dashboard stopped,
|
||||
hash-memory note, bash-mode failures, hook header rows,
|
||||
@mentions / @url, shell confirm, checkpoint saved, plan
|
||||
continue / stop / revise. (#447)
|
||||
- i18n(slash): four lagging slash handlers — `web-search-engine.ts`
|
||||
was 0% localized, plus `mcp.ts` / `plans.ts` / `semantic.ts` had
|
||||
small gaps. ~22 new keys. (#448)
|
||||
- i18n(dashboard): translate the plan `idle` status pill — the
|
||||
`active` / `done` pills already used `t()` but the third branch
|
||||
was hardcoded English. (#443)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(search): honor abort during recursive fs scans — Esc during a
|
||||
large `search` tool call now exits promptly instead of finishing
|
||||
the walk. (#400)
|
||||
- fix(ui): refresh model badge on dashboard preset change and /pro
|
||||
turns — the header pill stayed stale across server-side
|
||||
preset/pro switches. (#403)
|
||||
- fix(permissions): match Windows project keys case-insensitively —
|
||||
the project allowlist hashed `C:\Foo` differently from `c:\foo`,
|
||||
causing entries to "disappear" depending on which case the cwd
|
||||
carried. (#402)
|
||||
- fix(prompt): inline short single-line pastes verbatim — the long-
|
||||
paste collapser was firing on tiny one-liners and burying them
|
||||
behind a "(N chars pasted)" placeholder. (#397)
|
||||
|
||||
**Tests / refactor / docs:**
|
||||
|
||||
- test(mcp): cover startup summary states (#396)
|
||||
- chore: improve loop.ts tests (#271)
|
||||
- refactor(ui): quiet chat-screen chrome — fewer always-on rows on
|
||||
the welcome card so the prompt stays close to the top. (#411)
|
||||
- docs(readme): canonical install + subcommand cheatsheet (#408)
|
||||
- docs(issues): split off display/rendering template, collect
|
||||
terminal host info inline. (#412)
|
||||
- docs(dashboard): PARITY.md audit — bucket E of #369. (#439)
|
||||
|
||||
## [0.30.5] — 2026-05-07
|
||||
|
||||
**Headline:** three contributor-led follow-ups from the #350 RFC plus
|
||||
the #366 onboarding piece. The repeat-loop storm guard now exempts
|
||||
obviously-safe inspector tools (`read_file`, `list_directory`,
|
||||
`job_output`, `list_jobs`) so a model intentionally re-reading state
|
||||
isn't flagged as stuck. A new `wait_for_job(jobId, timeoutMs?)` tool
|
||||
replaces N-iteration polling loops with a single blocking call —
|
||||
returns the moment the job exits or emits new output. And `/skill
|
||||
new <name>` finally provides the missing creation entry-point for
|
||||
user skills, scaffolding a stub with the right frontmatter so
|
||||
first-time users don't have to read the source to author a skill.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(storm): add `stormExempt` flag on `ToolDefinition`, set on
|
||||
`read_file`, `list_directory`, `job_output`, `list_jobs`. Cheap
|
||||
state-inspection no longer trips the repeat-loop guard. Mutating
|
||||
tools and unknown tools still go through the existing window-and-
|
||||
threshold check. (#350, PR #388 by @ctharvey)
|
||||
- feat(jobs): new `wait_for_job(jobId, timeoutMs?)` shell tool —
|
||||
blocks until the job exits or emits new output, bounded by
|
||||
`timeoutMs` (default 5000, clamped to 0..30000). Returns
|
||||
`{ exited, exitCode, latestOutput }`; `latestOutput` is the
|
||||
delta since the call started, not the full buffer. Rides the
|
||||
existing job registry's exit + output events; one call replaces
|
||||
N polling iterations and is token-cheaper than the prior
|
||||
re-call-job_output loop. (#350, PR #390 by @ctharvey)
|
||||
- feat(skills): `/skill new <name>` scaffolds a stub at
|
||||
`<project>/.reasonix/skills/<name>.md` with minimal frontmatter
|
||||
+ a comment block listing the optional knobs (`runAs`,
|
||||
`allowed-tools`, `model`). `/skill new <name> --global` writes
|
||||
under `~/.reasonix/skills/` for cross-project use; auto-falls-
|
||||
back to global when there's no project root. The empty
|
||||
`/skill list` now ends with an explicit "no remote registry yet
|
||||
— scaffold one with `/skill new <name>`" line so users don't
|
||||
hunt for a marketplace that doesn't exist. (#366, PR #394)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(skills): atomic create with `wx` flag — close the TOCTOU
|
||||
race between `existsSync(...)` and `writeFileSync(...)` that
|
||||
CodeQL flagged. The existence check IS the atomic write now;
|
||||
`EEXIST` from a parallel writer surfaces as the same "skill
|
||||
already exists" error instead of silently overwriting. (PR #394)
|
||||
|
||||
## [0.30.4] — 2026-05-07
|
||||
|
||||
**Headline:** sweep of the user-reported bug + onboarding queue from
|
||||
the 0.30.2 / 0.30.3 launch day. Resume now restores the full session
|
||||
state (cache hit %, cost, last context bar — previously they all
|
||||
showed zero on a fresh boot until the first turn landed). The model
|
||||
pill on assistant cards reflects the model that actually answered
|
||||
after `/model` or `/preset` switches it. Bare `/model` opens an
|
||||
interactive picker — typed-id entry stays for power users.
|
||||
PowerShell users get Shift+Tab back via three additional encodings
|
||||
(modifier-encoded back-tab, modifyOtherKeys, Kitty keyboard). And a
|
||||
class of "junk text after exit" on Linux/fish (terminal-feature
|
||||
replies leaking into the parent shell) gets a defensive stdin drain
|
||||
in the exit path.
|
||||
|
||||
`--dir` is now discoverable for beginners — surfaced in the welcome
|
||||
banner, the `/status` panel, the filesystem sandbox-escape error,
|
||||
and a Getting Started callout in both READMEs.
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(stats): persist cache totals + `lastPromptTokens` across
|
||||
resume. `SessionMeta` only carried `totalCostUsd` / `turnCount`,
|
||||
so on every resume `/status` showed 0 context + 0% cache hit until
|
||||
the first turn actually fired (even though the prefix was already
|
||||
cached, costing $0.01 per turn). Three new fields are persisted
|
||||
per-turn and seeded into `SessionStats` on resume; the existing
|
||||
carryover plumbing now covers cache + last context.
|
||||
(#364, PR #384)
|
||||
- fix(ui): `/model <id>` and `/preset {auto,flash,pro}` now update
|
||||
the active model in the agent store so the next assistant card
|
||||
pill reflects the new selection. Previously `state.session.model`
|
||||
was set once in `initialState()` and never mutated, so the pill
|
||||
showed the launch-time model regardless of what actually answered
|
||||
the turn. New `session.model.change` event; cards already opened
|
||||
keep their captured model so mid-turn auto-escalation doesn't
|
||||
retroactively relabel. (#372, PR #385)
|
||||
- fix(input): recognize three additional Shift+Tab encodings for
|
||||
PowerShell hosts and modern terminals — `\x1b[1;2Z` (modifier-
|
||||
encoded back-tab some PowerShell hosts emit), `\x1b[27;2;9~`
|
||||
(modifyOtherKeys level 2, which we already enable on startup),
|
||||
`\x1b[9;2u` (Kitty keyboard envelope). Without these the edit-
|
||||
mode cycle was silently dropped on PowerShell.
|
||||
`/mode` typed fallback continues to work. (#373, PR #386)
|
||||
- fix(tty): drain pending feature-detection replies on exit. Linux
|
||||
reporters saw `^[]11;rgb:...^[\^[[33;1R^[[?62;1;4c` printed by
|
||||
fish / bash after exiting reasonix — those bytes are responses to
|
||||
OSC 11 / CPR / DA1 queries the runtime emits during startup that
|
||||
sit in stdin's queue until exit. New `drainTtyResponses(50ms)`
|
||||
reads-and-discards anything queued before control returns to the
|
||||
parent shell. Layered on top of 0.30.3's alt-screen mitigation
|
||||
(`--no-alt-screen` users get the fix too). (#365, PR #391)
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(ui): bare `/model` opens an interactive model picker — arrow-
|
||||
key list, current model marked, `[r]` refreshes the catalog, esc
|
||||
cancels. Seeds from the live DeepSeek catalog
|
||||
(`useSessionInfo.listModels()`); falls back to the four known
|
||||
DeepSeek ids when the catalog hasn't loaded yet so the picker
|
||||
isn't empty on first open. The current id is always included even
|
||||
when the API didn't return it. `/model <id>` typed entry stays
|
||||
for power users. (#371, PR #387)
|
||||
- feat(ui): surface `--dir` / pinned workspace for first-time users.
|
||||
WelcomeBanner shows the workspace + relaunch hint in code mode;
|
||||
`/status` adds a `workspace <path> · pinned at launch` line; the
|
||||
filesystem sandbox-escape error points at `reasonix code --dir
|
||||
<path>` instead of just dropping a raw error; both READMEs gain a
|
||||
Getting Started subsection on `--dir`. No new slash command —
|
||||
mid-session retargeting is intentionally not supported (the
|
||||
message log + memory paths get tangled with stale roots).
|
||||
(#370, PR #389)
|
||||
|
||||
## [0.30.3] — 2026-05-07
|
||||
|
||||
**Headline:** the chat scroll rewrite lands. Ink 5.2 → 7.0.2 / React
|
||||
18.3 → 19.2, the cell-diff renderer is retired, and `reasonix code` /
|
||||
`reasonix chat` default to alt-screen with row-precision virtual
|
||||
scroll. PgUp / PgDn / mouse wheel scroll history; an empty prompt + ↑
|
||||
also scrolls (Ctrl+P / Ctrl+N still recalls prompt history). When
|
||||
scrolled away from bottom, the prompt hides and a `📖 reading
|
||||
history — End / PgDn to return` hint appears. Resize-ghost dividers
|
||||
and `<Static>`-related scroll-yank artifacts are gone with the
|
||||
renderer that produced them. `--no-alt-screen` keeps the legacy
|
||||
in-shell-scrollback behavior.
|
||||
|
||||
`web_search` gains a configurable backend — Mojeek stays the default,
|
||||
but `/web-search-engine searxng <url>` switches to a self-hosted
|
||||
SearXNG instance for users whose network blocks Mojeek. And the MCP
|
||||
filesystem sandbox now fails with an actionable
|
||||
`mkdir -p '<path>'` hint instead of a raw Node stack when the
|
||||
configured directory doesn't exist; the wizard offers to create it
|
||||
inline at config time.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(ui): row-precision virtual scroll on Ink 7 + React 19.
|
||||
`<Static>` retired (incompatible with alt-screen reflow);
|
||||
`React.memo(CardRenderer)` plus reference-stable cards in the
|
||||
reducer skip the reconciler on unchanged history. `useChatScroll`
|
||||
drives an outer `overflow=hidden` clip + inner `marginTop=-N`
|
||||
slide; `useBoxMetrics` reports inner / outer heights so bounds
|
||||
clamp and auto-pin to bottom on new content. `App` owns
|
||||
PgUp/PgDn/End/wheel; PromptInput hands off ↑/↓ on empty buffer
|
||||
when pinned + idle. Ticker migrated to Ink 7's shared
|
||||
`useAnimation`. (PR #380)
|
||||
- feat(web): configurable `web_search` backend with SearXNG support.
|
||||
`/web-search-engine` shows / switches the active engine; URL is
|
||||
persisted to `~/.reasonix/config.json`. Mojeek remains the default;
|
||||
the original Mojeek path is preserved as `searchMojeek()`. Protocol
|
||||
auto-normalizes (`localhost:8080` → `http://...`); an unreachable
|
||||
SearXNG endpoint surfaces an install hint instead of a raw fetch
|
||||
error. (PR #338)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(mcp): preflight the filesystem sandbox directory before
|
||||
spawning `@modelcontextprotocol/server-filesystem`. Missing
|
||||
directories now throw `MCP filesystem sandbox '<path>' does not
|
||||
exist — create it with: mkdir -p '<path>'` instead of a raw Node
|
||||
stack from inside `npx`'s child. The init wizard adds an inline
|
||||
`[Y] create it (mkdir -p) / [N] enter a different path` confirm
|
||||
step when the user types a path that doesn't exist, so bad config
|
||||
never reaches disk. Spawn-time path deliberately does not
|
||||
auto-mkdir — by then the user may not remember writing the
|
||||
config. (#362, PR #379)
|
||||
- fix(readme): website URLs corrected from `/reasonix/` to
|
||||
`/DeepSeek-Reasonix/`. (PR #375)
|
||||
|
||||
**Chores:**
|
||||
|
||||
- chore(issue-template): bug template now asks for shell + terminal,
|
||||
and the model-id examples track the current DeepSeek model
|
||||
lineup. (PR #378)
|
||||
|
||||
## [0.30.2] — 2026-05-07
|
||||
|
||||
**Headline:** five user-visible polish items from the @dacec354 triage
|
||||
batch. The streaming reply now carries a live `42 t/s` throughput pill
|
||||
(plus a `1.2k tok · 42 t/s` summary on settled), and `ctrl-o` toggles a
|
||||
full-tail view so a long plan / todo can be read while it's still
|
||||
being written. The auto-mode undo banner gains a `space`-to-pause
|
||||
keybind for users who want a beat to think before the 5-second window
|
||||
expires. SessionPicker and the dashboard's session-cost displays both
|
||||
respect the user's wallet currency now — USD wallets see `$0.05`, CNY
|
||||
wallets see `¥0.36` end-to-end. And a long-standing scrollback bug
|
||||
that left the "reasoning…" spinner spinning forever after reasoning
|
||||
ended is fixed.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(ui): live `42 t/s` pill on the streaming reply card; settled
|
||||
card shows `1.2k tok · 42 t/s` summary. Computed via the bundled
|
||||
DeepSeek tokenizer; gated below 4 tokens / 500 ms so the first
|
||||
chunk doesn't print bogus rates. Re-renders ride the slow tick so
|
||||
the rate keeps updating during chunk silence. (#334, PR #356)
|
||||
- feat(ui): `space` toggles pause / resume on the auto-mode 5-second
|
||||
undo countdown. While paused the bar freezes at the captured
|
||||
fraction, the badge swaps to `Ns · paused`, and pressing `space`
|
||||
again resumes from where it stopped. The `u` and `space` keybinds
|
||||
share the same modal-and-prompt-empty gating. (#337, PR #356)
|
||||
- feat(ui): `ctrl-o` toggles "expanded" mode on the live streaming
|
||||
card. Expanded shows up to 60 visual lines (capped so the card
|
||||
can't swallow the whole viewport) plus a `⋯ N earlier lines above`
|
||||
hint when content overflows. Auto-resets to collapsed at turn end.
|
||||
A `expanded ⌃o` / `preview ⌃o` pill in the card header advertises
|
||||
the keybind. (#335, #337, PR #359)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(ui): `splitCardStream` only treated the LAST card as live,
|
||||
committing every earlier card to Ink's `<Static>`. When the model
|
||||
streamed reasoning then content (or kicked off a tool card), the
|
||||
reasoning card was no longer last — it got frozen into `<Static>`
|
||||
while still `streaming: true`. `<Static>` doesn't re-render frozen
|
||||
items, so when `reasoning.end` later set `streaming: false`, the
|
||||
spinner kept spinning forever. The split now scans for the first
|
||||
unsettled card and keeps everything from that index onward live;
|
||||
a card only commits to `<Static>` once it's settled AND every
|
||||
earlier card is too. (PR #358)
|
||||
- fix(ui): SessionPicker hardcoded `¥` and ran USD → CNY itself, so
|
||||
USD-wallet users saw `¥X.XX` in the session list. `SessionMeta`
|
||||
gains `balanceCurrency`; App.tsx writes the live wallet currency
|
||||
alongside `totalCostUsd` on each turn save. Picker accepts a
|
||||
`walletCurrency` prop and falls back to each row's stored
|
||||
currency. Cost rendering routes through the shared `formatCost()`
|
||||
helper. (#312, PR #357)
|
||||
- fix(dashboard): cost displays were hardcoded to `$` via `fmtUsd()`,
|
||||
so a CNY-wallet user saw `session $0.5190` in the dashboard while
|
||||
the same session read `¥0.024` in the CLI — both the symbol AND
|
||||
the magnitude diverged because no conversion happened. Dashboard
|
||||
now has its own `fmtCost(usd, currency)` mirroring the CLI's
|
||||
conversion (CNY × 7.2). Overview current-session cost, cost-trend
|
||||
day average, and the chat panel rail / status-bar costs all
|
||||
thread the wallet currency from the cockpit balance. Claude-
|
||||
equivalent comparisons in `usage.ts` stay USD by design — Claude's
|
||||
API is USD-priced regardless of the user's wallet. (PR #360)
|
||||
|
||||
## [0.30.1] — 2026-05-07
|
||||
|
||||
**Headline:** two TUI ghost-rendering fixes for issues that only showed
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/reasonix/">Website</a>
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">Architecture</a>
|
||||
·
|
||||
@@ -46,6 +46,49 @@
|
||||
|
||||
<br/>
|
||||
|
||||
## Web search
|
||||
|
||||
Reasonix includes `web_search` and `web_fetch` tools. By default it uses **Mojeek** (no setup required). You can switch to a **self-hosted SearXNG** instance — a metasearch engine that aggregates whatever upstream engines your instance is configured for.
|
||||
|
||||
### Switching engines (persists to disk)
|
||||
|
||||
The `/search-engine` slash command (alias `/se`) writes your choice to `~/.reasonix/config.json` immediately — it survives restarts:
|
||||
|
||||
```
|
||||
/search-engine mojeek # default, no external deps
|
||||
/search-engine searxng # SearXNG at http://localhost:8080
|
||||
/search-engine searxng http://192.168.1.100:8888 # custom endpoint
|
||||
```
|
||||
|
||||
Equivalent `~/.reasonix/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"webSearchEngine": "searxng",
|
||||
"webSearchEndpoint": "http://localhost:8080"
|
||||
}
|
||||
```
|
||||
|
||||
The tool picks up the change on the next call — no restart needed.
|
||||
|
||||
### Starting SearXNG
|
||||
|
||||
```sh
|
||||
podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng
|
||||
# or: docker run -d -p 8080:8080 searxng/searxng
|
||||
```
|
||||
|
||||
Verify it's running:
|
||||
|
||||
```sh
|
||||
curl http://localhost:8080/search?q=test
|
||||
# → HTML search results page
|
||||
```
|
||||
|
||||
> **Note:** The endpoint must include the protocol (`http://`). `localhost:8080` alone will fail — the tool will show a clear error telling you to install SearXNG if the server is unreachable.
|
||||
|
||||
<br/>
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
@@ -55,6 +98,37 @@ npx reasonix code # paste a DeepSeek API key on first run; persists after
|
||||
|
||||
Requires Node ≥ 22. Tested on macOS · Linux · Windows (PowerShell · Git Bash · Windows Terminal). Get a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
|
||||
|
||||
`npx` is the recommended path — no global install, always picks up the latest version. If you'll use Reasonix daily and want `reasonix` on your `PATH`, run `reasonix update` once and it'll do the `npm install -g` for you.
|
||||
|
||||
### Subcommand cheatsheet
|
||||
|
||||
| Command | When to use |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | Coding agent rooted at a project. **Start here.** |
|
||||
| `reasonix chat` | Plain chat — no filesystem tools, just a conversation with persisted history. |
|
||||
| `reasonix run "task"` | One-shot, streams the answer to stdout. Good for shell pipes. |
|
||||
| `reasonix doctor` | Environment health check (Node version, API key, MCP wiring). |
|
||||
| `reasonix update` | Upgrade Reasonix itself. |
|
||||
|
||||
Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`) are listed in `reasonix --help` and on the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
|
||||
|
||||
**Working in a different folder:** Reasonix scopes filesystem tools to the launch directory. To work elsewhere, pass `--dir`:
|
||||
|
||||
```bash
|
||||
npx reasonix code --dir /path/to/project # or use a relative path
|
||||
```
|
||||
|
||||
Mid-session switching isn't supported by design (the message log + memory paths get tangled with stale roots). Quit and relaunch with a new `--dir` to retarget. `/status` always shows the current pinned workspace.
|
||||
|
||||
**Author your first skill:** Skills are markdown playbooks the model can invoke (`/skill <name>`). There's no remote registry yet — you author them directly:
|
||||
|
||||
```bash
|
||||
/skill new my-skill # scaffolds <project>/.reasonix/skills/my-skill.md
|
||||
/skill new my-skill --global # or under ~/.reasonix/skills for cross-project use
|
||||
```
|
||||
|
||||
Edit the file (`description:` frontmatter + body), then `/skill list` to see it. Add `runAs: subagent` to the frontmatter to spawn an isolated subagent loop instead of inlining the body.
|
||||
|
||||
<br/>
|
||||
|
||||
## What makes Reasonix different
|
||||
@@ -90,8 +164,10 @@ Click any card to read the full architecture writeup → [Pillar 1](./docs/ARCHI
|
||||
| Cost profile | **low per task** | premium | subscription + use | varies |
|
||||
| DeepSeek prefix-cache | **engineered** | not applicable | not applicable | incidental |
|
||||
| Embedded web dashboard | yes | — | n/a (IDE) | — |
|
||||
| Configurable web search engine | `/search-engine` | — | — | — |
|
||||
| Persistent per-workspace sessions | yes | partial | n/a | — |
|
||||
| Plan mode · MCP · hooks · skills | yes | yes | yes | partial |
|
||||
| Web search (Mojeek + SearXNG) | yes | yes | yes | yes |
|
||||
| Open community development | yes | — | — | yes |
|
||||
|
||||
For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmarks/) — the numbers move with model pricing, so they live with the harness, not in the README.
|
||||
@@ -102,7 +178,7 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
|
||||
|
||||
- [**Architecture**](./docs/ARCHITECTURE.md) — the four pillars, cache-first loop, harvesting, scaffolds
|
||||
- [**Benchmarks**](./benchmarks/) — τ-bench-lite harness, transcripts, cost methodology
|
||||
- [**Website**](https://esengine.github.io/reasonix/) — getting started, dashboard mockup, TUI mockup
|
||||
- [**Website**](https://esengine.github.io/DeepSeek-Reasonix/) — getting started, dashboard mockup, TUI mockup
|
||||
- [**Contributing**](./CONTRIBUTING.md) — comment policy, error-handling rules, library-over-hand-rolled
|
||||
- [**Code of Conduct**](./CODE_OF_CONDUCT.md) · [**Security policy**](./SECURITY.md)
|
||||
|
||||
@@ -118,7 +194,7 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
|
||||
**Open Discussions — opinions wanted:**
|
||||
|
||||
- [#20 · CLI / TUI design](https://github.com/esengine/reasonix/discussions/20) — what's broken, what's missing, what would you change?
|
||||
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/reasonix/design/agent-dashboard.html)
|
||||
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)
|
||||
- [#22 · Future feature wishlist](https://github.com/esengine/reasonix/discussions/22) — what would you build into Reasonix next?
|
||||
|
||||
**Already using Reasonix and willing to help others discover it?** Publish blog posts, articles, screenshots, talks, or videos to [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell). The project has no marketing budget — community word of mouth is how new users find it. Sustained advocates earn the badge below, displayed next to the contributors wall once awarded:
|
||||
|
||||
+34
-3
@@ -7,7 +7,7 @@
|
||||
·
|
||||
<strong>简体中文</strong>
|
||||
·
|
||||
<a href="https://esengine.github.io/reasonix/">官方网站</a>
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">架构文档</a>
|
||||
·
|
||||
@@ -52,6 +52,37 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
|
||||
要求 Node ≥ 22。已在 macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)测过。[去拿 DeepSeek API Key →](https://platform.deepseek.com/api_keys) · 完整 flag 看 `reasonix code --help`。
|
||||
|
||||
`npx` 是推荐路径 —— 不用全局安装,每次都拿到最新版本。如果你天天用、想把 `reasonix` 装到 `PATH` 上,跑一次 `reasonix update` 就行,它会替你跑 `npm install -g`。
|
||||
|
||||
### 子命令速查
|
||||
|
||||
| 命令 | 适用场景 |
|
||||
|---|---|
|
||||
| `reasonix code [dir]` | 锁在某个项目根目录的编码 agent。**先用这个。** |
|
||||
| `reasonix chat` | 纯聊天 —— 不挂文件系统工具,只是带历史的对话。 |
|
||||
| `reasonix run "task"` | 一次性,把答案直接流到 stdout。适合 shell 管道。 |
|
||||
| `reasonix doctor` | 环境体检(Node 版本、API Key、MCP 接线)。 |
|
||||
| `reasonix update` | 升级 Reasonix 本身。 |
|
||||
|
||||
其他子命令(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)见 `reasonix --help` 和 [CLI 参考](https://esengine.github.io/DeepSeek-Reasonix/#cli)。
|
||||
|
||||
**在其他目录工作:** Reasonix 把文件系统工具作用域绑定在启动目录。要在别的目录工作,传 `--dir`:
|
||||
|
||||
```bash
|
||||
npx reasonix code --dir /path/to/project # 也可以用相对路径
|
||||
```
|
||||
|
||||
中途切换工作区是有意不支持的(消息日志和 memory 路径会和旧的根目录混在一起,状态错乱)。退出后用新的 `--dir` 重新启动来切换。`/status` 始终显示当前锁定的工作区。
|
||||
|
||||
**写第一个 Skill:** Skills 是模型可以调用的 markdown 剧本(`/skill <name>`)。暂无在线市场 —— 自己写:
|
||||
|
||||
```bash
|
||||
/skill new my-skill # 在 <project>/.reasonix/skills/my-skill.md 生成模板
|
||||
/skill new my-skill --global # 或者放到 ~/.reasonix/skills,跨项目共用
|
||||
```
|
||||
|
||||
编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 里加 `runAs: subagent` 会以独立 subagent 跑,而不是把正文内联进父 prompt。
|
||||
|
||||
<br/>
|
||||
|
||||
## Reasonix 的不同之处
|
||||
@@ -99,7 +130,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
|
||||
- [**架构**](./docs/ARCHITECTURE.md) —— 四大支柱、缓存优先循环、思维提取、脚手架
|
||||
- [**基准测试**](./benchmarks/) —— τ-bench-lite harness、transcript、成本方法论
|
||||
- [**官方网站**](https://esengine.github.io/reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
|
||||
- [**官方网站**](https://esengine.github.io/DeepSeek-Reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
|
||||
- [**贡献指南**](./CONTRIBUTING.md) —— 注释规则、错误处理、用现成库不手写
|
||||
- [**行为准则**](./CODE_OF_CONDUCT.md) · [**安全策略**](./SECURITY.md)
|
||||
|
||||
@@ -115,7 +146,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
|
||||
**正在征集意见的 Discussions:**
|
||||
|
||||
- [#20 · CLI / TUI 设计](https://github.com/esengine/reasonix/discussions/20) —— 哪里坏了、哪里少东西、哪里你会怎么改?
|
||||
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/reasonix/design/agent-dashboard.html)拍砖
|
||||
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)拍砖
|
||||
- [#22 · 未来功能愿望单](https://github.com/esengine/reasonix/discussions/22) —— 你希望 Reasonix 长出什么功能?
|
||||
|
||||
**正在使用 Reasonix,愿意让更多人了解它?** 欢迎将相关博客、文章、截图、演讲或视频发布到 [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell)。项目没有营销预算,新用户主要通过社区口碑找到这里。持续参与传播的用户将获得下方这枚徽章,颁发后会展示在贡献者墙旁:
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -4,6 +4,24 @@ export function fmtUsd(n: number | null | undefined): string {
|
||||
return `$${n.toFixed(n < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
/** Keep in sync with src/cli/ui/theme/tokens.ts USD_TO_CNY. */
|
||||
const USD_TO_CNY = 7.2;
|
||||
|
||||
/** USD-internal cost rendered in the wallet's display currency. Undefined currency → CNY (matches CLI default). */
|
||||
export function fmtCost(
|
||||
usd: number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
fractionDigits?: number,
|
||||
): string {
|
||||
if (usd === null || usd === undefined) return "—";
|
||||
const cur = currency ?? "CNY";
|
||||
const amount = cur === "CNY" ? usd * USD_TO_CNY : usd;
|
||||
if (amount === 0) return cur === "CNY" ? "¥0" : "$0";
|
||||
const sym = cur === "CNY" ? "¥" : cur === "USD" ? "$" : `${cur} `;
|
||||
const digits = fractionDigits ?? (Math.abs(amount) < 0.01 ? 6 : 4);
|
||||
return `${sym}${amount.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
export function fmtPct(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined) return "—";
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export type IntervalUnit = "s" | "m" | "h";
|
||||
|
||||
export interface LoopRunStatus {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
/** Wall-clock ms until the next fire — server reports a remaining duration, not an absolute. */
|
||||
nextFireMs: number;
|
||||
}
|
||||
|
||||
/** Quick-pick intervals in ms — covers the 95% of cases users actually run. */
|
||||
export const INTERVAL_PRESETS_MS: ReadonlyArray<{ ms: number; label: string }> = [
|
||||
{ ms: 30_000, label: "30s" },
|
||||
{ ms: 60_000, label: "1m" },
|
||||
{ ms: 5 * 60_000, label: "5m" },
|
||||
{ ms: 15 * 60_000, label: "15m" },
|
||||
{ ms: 60 * 60_000, label: "1h" },
|
||||
{ ms: 6 * 60 * 60_000, label: "6h" },
|
||||
];
|
||||
|
||||
const UNIT_TO_MS: Record<IntervalUnit, number> = {
|
||||
s: 1_000,
|
||||
m: 60_000,
|
||||
h: 60 * 60_000,
|
||||
};
|
||||
|
||||
const MIN_INTERVAL_MS = 5_000;
|
||||
const MAX_INTERVAL_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** Convert a "30" + "s" pair to ms, returning null if out of [5s, 6h]. */
|
||||
export function parseCustomInterval(value: string, unit: IntervalUnit): number | null {
|
||||
const n = Number.parseFloat(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const ms = Math.round(n * UNIT_TO_MS[unit]);
|
||||
if (ms < MIN_INTERVAL_MS || ms > MAX_INTERVAL_MS) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
/** Human-friendly "5m 12s" / "12s" / "2h 45m" — shows two largest non-zero units. */
|
||||
export function formatRemaining(ms: number): string {
|
||||
const safe = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(safe / 3600);
|
||||
const m = Math.floor((safe % 3600) / 60);
|
||||
const s = safe % 60;
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
@@ -6,15 +6,17 @@ import {
|
||||
ChoiceModal,
|
||||
EditReviewModal,
|
||||
type OnResolve,
|
||||
PickerModal,
|
||||
PlanModal,
|
||||
RevisionModal,
|
||||
ShellModal,
|
||||
ViewerModal,
|
||||
WorkspaceModal,
|
||||
parseToolArgs,
|
||||
} from "../components/chat-internals.js";
|
||||
import { MODE, TOKEN, api } from "../lib/api.js";
|
||||
import { appBus, showToast } from "../lib/bus.js";
|
||||
import { fmtUsd } from "../lib/format.js";
|
||||
import { fmtCost, fmtUsd } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
|
||||
@@ -684,7 +686,11 @@ export function ChatPanel() {
|
||||
? html`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "revision"
|
||||
? html`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: null
|
||||
: modal.kind === "picker"
|
||||
? html`<${PickerModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "viewer"
|
||||
? html`<${ViewerModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: null
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -791,6 +797,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
const showBudget = stats != null && typeof budgetUsd === "number" && budgetUsd > 0;
|
||||
const budgetPct = showBudget ? Math.min(120, (stats.totalCostUsd / budgetUsd) * 100) : 0;
|
||||
const budgetTone = budgetPct >= 100 ? "err" : budgetPct >= 80 ? "warn" : "";
|
||||
const walletCurrency = stats?.balance?.[0]?.currency;
|
||||
return html`
|
||||
<aside class="chat-rail">
|
||||
${activePlan ? html`<${ActivePlanCard} plan=${activePlan} />` : null}
|
||||
@@ -801,7 +808,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
<div class="rh">${t("chat.railSession")}</div>
|
||||
<div class="rail-kv"><span class="k">${t("chat.railTurns")}</span><span class="v">${stats.turns.toLocaleString()}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("chat.railPromptTok")}</span><span class="v">${stats.lastPromptTokens.toLocaleString()}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtUsd(stats.totalCostUsd)}</span></div>
|
||||
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtCost(stats.totalCostUsd, walletCurrency)}</span></div>
|
||||
<div class="progress-row" style="margin-top:8px">
|
||||
<span class="lbl">${t("chat.railCacheHit")}</span>
|
||||
<div class=${`progress ${cacheTone}`}><div class="progress-fill" style=${`width:${cachePct}%`}></div></div>
|
||||
@@ -819,7 +826,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
|
||||
<div class="progress-row">
|
||||
<span class="lbl">${t("chat.railSpend")}</span>
|
||||
<div class=${`progress ${budgetTone}`}><div class="progress-fill" style=${`width:${Math.min(100, budgetPct)}%`}></div></div>
|
||||
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtUsd(stats.totalCostUsd)} / ${fmtUsd(budgetUsd)}</span>
|
||||
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtCost(stats.totalCostUsd, walletCurrency)} / ${fmtCost(budgetUsd, walletCurrency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -989,11 +996,11 @@ function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
|
||||
</span>
|
||||
<span class="status-item">
|
||||
<span class="status-label">${t("chat.statusTurn")}</span>
|
||||
<code>${fmtUsd(stats.lastTurnCostUsd)}</code>
|
||||
<code>${fmtCost(stats.lastTurnCostUsd, balance?.currency)}</code>
|
||||
</span>
|
||||
<span class="status-item">
|
||||
<span class="status-label">${t("chat.statusSession")}</span>
|
||||
<code>${fmtUsd(stats.totalCostUsd)}</code>
|
||||
<code>${fmtCost(stats.totalCostUsd, balance?.currency)}</code>
|
||||
<span class="muted" style="font-size: 10px;">
|
||||
${t("chat.statusTurns", { count: stats.turns, s: stats.turns === 1 ? "" : "s" })}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fmtCompactNum, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
|
||||
import { budgetTone, deriveBudgetState } from "../lib/budget.js";
|
||||
import { fmtCompactNum, fmtCost, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
|
||||
import { html } from "../lib/html.js";
|
||||
import { usePoll } from "../lib/use-poll.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
@@ -65,6 +66,9 @@ interface OverviewData {
|
||||
toolCount?: number;
|
||||
cwd?: string;
|
||||
cockpit?: CockpitData;
|
||||
budgetUsd?: number | null;
|
||||
/** Cumulative session spend in USD — set when a session is attached. */
|
||||
sessionSpendUsd?: number | null;
|
||||
}
|
||||
|
||||
function kpi(label: string, value: unknown, delta?: unknown, deltaTone?: "up" | "down" | "flat") {
|
||||
@@ -107,6 +111,25 @@ function balanceKpi(c: CockpitData) {
|
||||
return kpi(t("overview.balance"), `${symbol}${c.balance.total}`, c.balance.currency, "flat");
|
||||
}
|
||||
|
||||
function budgetKpi(o: OverviewData) {
|
||||
const state = deriveBudgetState(o.budgetUsd, o.cockpit?.currentSession?.totalCostUsd ?? null);
|
||||
if (state.kind === "off") return null;
|
||||
const tone = budgetTone(state);
|
||||
const valueColor =
|
||||
tone === "err"
|
||||
? "color:var(--c-err)"
|
||||
: tone === "warn"
|
||||
? "color:var(--c-warn)"
|
||||
: "";
|
||||
return html`
|
||||
<div class="kpi cock-w-1">
|
||||
<div class="label">${t("overview.budget")}</div>
|
||||
<div class="value" style=${valueColor}>${fmtUsd(state.spent)} / ${fmtUsd(state.cap)}</div>
|
||||
<div class=${`progress ${tone}`} style="margin-top:4px"><div class="progress-fill" style=${`width:${Math.min(100, state.pct)}%`}></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function tokens7dKpi(c: CockpitData) {
|
||||
if (!c.tokens7d) return kpi(t("overview.tokens7d"), "—", t("overview.noUsageYet"), "flat");
|
||||
const d = deltaPctText(c.tokens7d.deltaPct);
|
||||
@@ -144,6 +167,7 @@ function currentSessionBlock(c: CockpitData) {
|
||||
`;
|
||||
}
|
||||
const s = c.currentSession;
|
||||
const currency = c.balance?.currency;
|
||||
return html`
|
||||
<div class="cock-list cock-w-2">
|
||||
<div class="ch"><span class="ttl">${t("overview.currentSession")}</span></div>
|
||||
@@ -153,7 +177,7 @@ function currentSessionBlock(c: CockpitData) {
|
||||
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:8px;font-family:var(--font-mono);font-size:11px">
|
||||
<div><span style="color:var(--fg-3)">${t("overview.promptTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.lastPromptTokens)}</div></div>
|
||||
<div><span style="color:var(--fg-3)">${t("overview.completionTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.completionTokens)}</div></div>
|
||||
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtUsd(s.totalCostUsd)}</div></div>
|
||||
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtCost(s.totalCostUsd, currency)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -185,7 +209,7 @@ function costTrendSpark(c: CockpitData) {
|
||||
return html`
|
||||
<div class="chart cock-w-2">
|
||||
<div class="chart-h"><span class="title">${t("overview.costTrend")}</span></div>
|
||||
<div class="chart-v">${fmtUsd(avg)}<span class="unit">${t("overview.dayAvg")}</span></div>
|
||||
<div class="chart-v">${fmtCost(avg, c.balance?.currency)}<span class="unit">${t("overview.dayAvg")}</span></div>
|
||||
<div class="chart-spark">
|
||||
<svg viewBox=${`0 0 ${w} ${h}`} preserveAspectRatio="none">
|
||||
<polyline fill="none" stroke="var(--c-brand)" stroke-width="1.5" points=${points} />
|
||||
@@ -284,6 +308,7 @@ export function OverviewPanel() {
|
||||
${tokens7dKpi(c)}
|
||||
${cacheHitKpi(c)}
|
||||
${toolCallsKpi(c)}
|
||||
${budgetKpi(o)}
|
||||
|
||||
${currentSessionBlock(c)}
|
||||
${costTrendSpark(c)}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -195,7 +195,7 @@ src/
|
||||
│ ├── skills.ts # list + invoke SKILL.md playbooks
|
||||
│ ├── subagent.ts # spawn_subagent — flash+high by default
|
||||
│ ├── plan.ts # submit_plan (review gate)
|
||||
│ └── web.ts # web_search, web_fetch
|
||||
│ └── web.ts # web_search, web_fetch (multi-engine: Mojeek or SearXNG)
|
||||
├── mcp/ # MCP client + bridge (stdio + SSE)
|
||||
├── memory.ts # ImmutablePrefix / AppendOnlyLog / VolatileScratch
|
||||
├── project-memory.ts # REASONIX.md loader
|
||||
|
||||
+1
-1
@@ -403,7 +403,7 @@ npx reasonix diff \
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cli">
|
||||
<section id="cli" class="cli">
|
||||
<div class="container">
|
||||
<h2 class="section-title" data-i18n="cli.title">CLI at a glance</h2>
|
||||
<pre class="code"><code>npx reasonix code [path] <span class="hash"># <span data-i18n="cli.code">coding mode scoped to path</span></span>
|
||||
|
||||
Generated
+427
-417
File diff suppressed because it is too large.
Load diff
+5
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.30.1",
|
||||
"version": "0.31.0",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -72,14 +72,12 @@
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"ink": "^5.1.0",
|
||||
"ink": "^7.0.2",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-reconciler": "^0.29.2",
|
||||
"react": "^19.2.6",
|
||||
"string-width": "^7.2.0",
|
||||
"yoga-layout": "^3.2.1",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -88,12 +86,12 @@
|
||||
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-reconciler": "^0.28.9",
|
||||
"@types/react": "^19.2.14",
|
||||
"@vitest/coverage-v8": "^2.1.5",
|
||||
"esbuild": "^0.21.5",
|
||||
"highlight.js": "^11.10.0",
|
||||
"htm": "^3.1.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"preact": "^10.22.0",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
|
||||
+37
-16
@@ -1,10 +1,17 @@
|
||||
import { render } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { loadApiKey, readConfig, searchEnabled } from "../../config.js";
|
||||
import {
|
||||
loadApiKey,
|
||||
readConfig,
|
||||
searchEnabled,
|
||||
webSearchEndpoint,
|
||||
webSearchEngine,
|
||||
} from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import type { CacheFirstLoop } from "../../loop.js";
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { type InspectionReport, inspectMcpServer } from "../../mcp/inspect.js";
|
||||
import { preflightStdioSpec } from "../../mcp/preflight.js";
|
||||
import { type McpClientHost, bridgeMcpTools } from "../../mcp/registry.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { SseTransport } from "../../mcp/sse.js";
|
||||
@@ -24,6 +31,7 @@ import { registerWebTools } from "../../tools/web.js";
|
||||
import { App } from "../ui/App.js";
|
||||
import { SessionPicker } from "../ui/SessionPicker.js";
|
||||
import { Setup } from "../ui/Setup.js";
|
||||
import { drainTtyResponses } from "../ui/drain-tty.js";
|
||||
import { KeystrokeProvider } from "../ui/keystroke-context.js";
|
||||
import { formatMcpLifecycleEvent } from "../ui/mcp-lifecycle.js";
|
||||
import { formatMcpSlowToast } from "../ui/mcp-toast.js";
|
||||
@@ -101,6 +109,7 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
|
||||
: ctx.getRequestedCount() === 1 && ctx.getMcpPrefix()
|
||||
? (ctx.getMcpPrefix() as string)
|
||||
: "";
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const transport: McpTransport =
|
||||
spec.transport === "sse"
|
||||
? new SseTransport({ url: spec.url })
|
||||
@@ -300,6 +309,13 @@ export interface ChatOptions {
|
||||
* URL is visible in the status bar.
|
||||
*/
|
||||
noDashboard?: boolean;
|
||||
/**
|
||||
* Render into the terminal's alternate screen buffer. Default true —
|
||||
* alt-screen avoids the scrollback-mode resize/wrap ghost class. Pass
|
||||
* false (CLI: `--no-alt-screen`) when the chat output needs to remain
|
||||
* in shell scrollback after exit.
|
||||
*/
|
||||
altScreen?: boolean;
|
||||
}
|
||||
|
||||
interface RootProps extends ChatOptions {
|
||||
@@ -436,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
|
||||
@@ -444,7 +462,10 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
// a question needs info fresher than its training data.
|
||||
if (searchEnabled()) {
|
||||
if (!tools) tools = new ToolRegistry();
|
||||
registerWebTools(tools);
|
||||
registerWebTools(tools, {
|
||||
webSearchEngine: webSearchEngine(),
|
||||
webSearchEndpoint: webSearchEndpoint(),
|
||||
});
|
||||
}
|
||||
|
||||
// Memory tools — available in every session, not just code mode.
|
||||
@@ -475,17 +496,6 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
const showPicker =
|
||||
!opts.session && !opts.forceResume && listSessionsForWorkspace(launchWorkspace).length > 0;
|
||||
|
||||
// No startup clear, no resize listener. Earlier attempts wrote
|
||||
// various combinations of \x1b[2J / \x1b[3J / cursor-home to
|
||||
// present a 'clean canvas' on launch and to neutralize Ink's
|
||||
// eraseLines miscount on resize, but on xterm.js-based terminals
|
||||
// (VSCode integrated terminal in particular) those sequences
|
||||
// interfere with scrollback in ways that make wheel-up scroll
|
||||
// dead. Letting Ink mount directly leaves the user's previous
|
||||
// shell prompt visible above (scrolling up just works) and
|
||||
// accepts the resize-ghost / launch-noise tradeoffs as known
|
||||
// limitations — `/clear` is the manual reset.
|
||||
|
||||
const { waitUntilExit } = render(
|
||||
<Root
|
||||
initialKey={initialKey}
|
||||
@@ -498,12 +508,23 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
{...opts}
|
||||
session={resolvedSession}
|
||||
/>,
|
||||
// patchConsole:false — winpty/MINTTY redraw-glitch source.
|
||||
{ exitOnCtrlC: true, patchConsole: false },
|
||||
{
|
||||
exitOnCtrlC: true,
|
||||
// patchConsole:false — winpty/MINTTY redraw-glitch source.
|
||||
patchConsole: false,
|
||||
incrementalRendering: true,
|
||||
// Default true — alt-screen is the only mode without scrollback-
|
||||
// reflow ghosting. `--no-alt-screen` opts back into scrollback mode
|
||||
// for users who need chat output preserved in shell history on exit.
|
||||
alternateScreen: opts.altScreen !== false,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await waitUntilExit();
|
||||
} finally {
|
||||
await runtime.closeAll();
|
||||
// Eat any pending terminal-feature-detection responses (#365) so the
|
||||
// parent shell doesn't print them as junk after exit.
|
||||
await drainTtyResponses();
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,8 @@ export interface CodeOptions {
|
||||
systemAppend?: string;
|
||||
/** Path to a UTF-8 text file whose contents are appended to the code system prompt. */
|
||||
systemAppendFile?: string;
|
||||
/** Default true. Pass false (CLI: `--no-alt-screen`) to keep chat output in shell scrollback. */
|
||||
altScreen?: boolean;
|
||||
}
|
||||
|
||||
export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
@@ -190,5 +192,6 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
forceResume: opts.forceResume,
|
||||
forceNew: opts.forceNew,
|
||||
noDashboard: opts.noDashboard,
|
||||
altScreen: opts.altScreen,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
/** `reasonix mcp browse` — Ink TUI for the MCP marketplace. Lazy-loads pages on scroll. */
|
||||
|
||||
import { Box, Text, render, useApp, useInput } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { inspectMcpServer } from "../../mcp/inspect.js";
|
||||
import type { InspectionReport } from "../../mcp/inspect.js";
|
||||
import { preflightStdioSpec } from "../../mcp/preflight.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { SseTransport } from "../../mcp/sse.js";
|
||||
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
|
||||
@@ -15,6 +16,7 @@ export interface McpInspectOptions {
|
||||
|
||||
export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void> {
|
||||
const spec = parseMcpSpec(opts.spec);
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const transport: McpTransport =
|
||||
spec.transport === "sse"
|
||||
? new SseTransport({ url: spec.url })
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { preflightStdioSpec } from "../../mcp/preflight.js";
|
||||
import { bridgeMcpTools } from "../../mcp/registry.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { SseTransport } from "../../mcp/sse.js";
|
||||
@@ -103,6 +104,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
: requestedSpecs.length === 1 && opts.mcpPrefix
|
||||
? opts.mcpPrefix
|
||||
: "";
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const transport: McpTransport =
|
||||
spec.transport === "sse"
|
||||
? new SseTransport({ url: spec.url })
|
||||
|
||||
@@ -113,6 +113,7 @@ program
|
||||
.option("--harvest", t("ui.harvestHint"))
|
||||
.option("--budget <usd>", t("ui.budgetHint"), (v) => Number.parseFloat(v))
|
||||
.option("--no-dashboard", t("ui.noDashboard"))
|
||||
.option("--no-alt-screen", "keep chat output in shell scrollback (legacy mode, ghost-prone)")
|
||||
.option("--system-append <prompt>", t("ui.systemAppendHint"))
|
||||
.option("--system-append-file <path>", t("ui.systemAppendFileHint"))
|
||||
.action(async (dir: string | undefined, opts) => {
|
||||
@@ -128,6 +129,7 @@ program
|
||||
noDashboard: opts.dashboard === false,
|
||||
systemAppend: opts.systemAppend,
|
||||
systemAppendFile: opts.systemAppendFile,
|
||||
altScreen: opts.altScreen !== false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +157,7 @@ program
|
||||
.option("--mcp-prefix <str>", t("ui.mcpPrefixHint"))
|
||||
.option("--no-config", t("ui.noConfigHint"))
|
||||
.option("--no-dashboard", t("ui.noDashboard"))
|
||||
.option("--no-alt-screen", "keep chat output in shell scrollback (legacy mode, ghost-prone)")
|
||||
.action(async (opts) => {
|
||||
const defaults = resolveDefaults({
|
||||
model: opts.model,
|
||||
@@ -190,6 +193,7 @@ program
|
||||
forceResume: continueOpts.forceResume,
|
||||
forceNew: !!opts.new,
|
||||
noDashboard: opts.dashboard === false,
|
||||
altScreen: opts.altScreen !== false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+355
-131
@@ -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,9 +77,11 @@ 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";
|
||||
import { ModelPicker } from "./ModelPicker.js";
|
||||
import { PlanCheckpointConfirm } from "./PlanCheckpointConfirm.js";
|
||||
import { PlanConfirm, type PlanConfirmChoice } from "./PlanConfirm.js";
|
||||
import { PlanRefineInput } from "./PlanRefineInput.js";
|
||||
@@ -84,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";
|
||||
@@ -96,6 +108,7 @@ import {
|
||||
} from "./hooks/handle-stream-events.js";
|
||||
import { handleToolEvent } from "./hooks/handle-tool-event.js";
|
||||
import { useAgentSession } from "./hooks/useAgentSession.js";
|
||||
import { useChatScroll } from "./hooks/useChatScroll.js";
|
||||
import { useCodeMode } from "./hooks/useCodeMode.js";
|
||||
import { useInputRecall } from "./hooks/useInputRecall.js";
|
||||
import { useLoopMode } from "./hooks/useLoopMode.js";
|
||||
@@ -104,6 +117,7 @@ import { useScrollback } from "./hooks/useScrollback.js";
|
||||
import { useTranscriptWriter } from "./hooks/useTranscriptWriter.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { CardStream } from "./layout/CardStream.js";
|
||||
import { LiveExpandContext } from "./layout/LiveExpandContext.js";
|
||||
import {
|
||||
ModeStatusBar,
|
||||
OngoingToolRow,
|
||||
@@ -126,6 +140,7 @@ import { TurnTranslator } from "./state/TurnTranslator.js";
|
||||
import { cardsToDashboardMessages } from "./state/cards-to-messages.js";
|
||||
import { hydrateCardsFromMessages } from "./state/hydrate.js";
|
||||
import { AgentStoreProvider, useAgentState, useAgentStore } from "./state/provider.js";
|
||||
import { FG } from "./theme/tokens.js";
|
||||
import { TickerProvider } from "./ticker.js";
|
||||
import { useCompletionPickers } from "./useCompletionPickers.js";
|
||||
import { useEditHistory } from "./useEditHistory.js";
|
||||
@@ -297,8 +312,15 @@ function AppInner({
|
||||
s.cards.some((c) => c.kind === "user" || c.kind === "streaming"),
|
||||
);
|
||||
const isStreaming = useAgentState((s) => s.cards.some((c) => c.kind === "streaming" && !c.done));
|
||||
const chatScroll = useChatScroll();
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
// ctrl-o toggles full-tail view on the live streaming card.
|
||||
// Auto-resets at the end of every turn so the next reply starts collapsed.
|
||||
const [liveExpand, setLiveExpand] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isStreaming && liveExpand) setLiveExpand(false);
|
||||
}, [isStreaming, liveExpand]);
|
||||
const [languageVersion, setLanguageVersion] = useState(0);
|
||||
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
|
||||
// Live MCP server list: initialized from the boot-time prop, then
|
||||
@@ -359,39 +381,6 @@ function AppInner({
|
||||
};
|
||||
}, [stdout]);
|
||||
|
||||
// Resize-suppression state. While the user is dragging the
|
||||
// terminal corner, OS emits a stream of resize events at high
|
||||
// frequency. Each one would trigger Ink to re-render — and our
|
||||
// per-tick animations (wordmark gradient, prompt bar flow,
|
||||
// cursor blink) keep firing at 120 ms — both with stale
|
||||
// `eraseLines(N)` counts because the previous frame's logical
|
||||
// height is no longer the visible height after wrap reflows.
|
||||
// Result: ghost copies of the StatsPanel pile up.
|
||||
//
|
||||
// Fix: detect resize bursts and freeze the global ticker while
|
||||
// they're in flight. With the ticker frozen, no re-render fires
|
||||
// from animations during the resize storm. Once the user stops
|
||||
// dragging (no resize event for ~400 ms), the ticker resumes
|
||||
// and one clean re-render kicks in. The hard-clear in chat.tsx's
|
||||
// resize listener handles the single transition ghost.
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!stdout || !stdout.isTTY) return;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onResize = () => {
|
||||
setIsResizing(true);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
setIsResizing(false);
|
||||
timer = null;
|
||||
}, 400);
|
||||
};
|
||||
stdout.on("resize", onResize);
|
||||
return () => {
|
||||
stdout.off("resize", onResize);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [stdout]);
|
||||
// Subagent UI wiring: live activity row + sink ref the loop closure
|
||||
// captures. Must be declared BEFORE loop construction so the
|
||||
// subagentRunner closure can read the ref. The wallet-currency thunk
|
||||
@@ -433,6 +422,7 @@ function AppInner({
|
||||
undoBanner,
|
||||
recordEdit,
|
||||
armUndoBanner,
|
||||
toggleUndoPause,
|
||||
codeUndo,
|
||||
codeHistory,
|
||||
codeShowEdit,
|
||||
@@ -545,8 +535,13 @@ 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`). */
|
||||
const [pendingModelPicker, setPendingModelPicker] = useState(false);
|
||||
// Stashed plan + intent while the user types free-form feedback
|
||||
// (refinement or last instructions on approve). When the picker
|
||||
// returns "refine" or "approve", we defer the loop-resume and show
|
||||
@@ -602,6 +597,24 @@ function AppInner({
|
||||
options: ChoiceOption[];
|
||||
allowCustom: boolean;
|
||||
} | null>(null);
|
||||
// Truthy when any pending modal owns the screen — gates global
|
||||
// hotkeys (chat-scroll, etc.) so they don't fire behind a picker.
|
||||
const modalOpen =
|
||||
!!pendingShell ||
|
||||
!!pendingPlan ||
|
||||
!!pendingReviseEditor ||
|
||||
!!pendingSessionsPicker ||
|
||||
!!pendingCheckpointPicker ||
|
||||
!!pendingMcpHub ||
|
||||
pendingModelPicker ||
|
||||
!!stagedInput ||
|
||||
!!pendingEditReview ||
|
||||
walkthroughActive ||
|
||||
!!pendingChoice ||
|
||||
!!stagedChoiceCustom ||
|
||||
!!pendingRevision ||
|
||||
!!stagedCheckpointRevise ||
|
||||
!!pendingCheckpoint;
|
||||
// Plan-mode indicator — displayed in the StatsPanel, mirrored onto
|
||||
// the ToolRegistry so dispatch enforces read-only. Toggled via the
|
||||
// `/plan` slash and PlanConfirm picker. Ephemeral — not persisted
|
||||
@@ -666,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
|
||||
@@ -890,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;
|
||||
@@ -915,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
|
||||
@@ -1077,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
|
||||
@@ -1096,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
|
||||
@@ -1121,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,
|
||||
@@ -1137,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]);
|
||||
@@ -1152,6 +1185,20 @@ function AppInner({
|
||||
// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
|
||||
const quitProcess = useQuit(transcriptRef);
|
||||
|
||||
// PgUp / PgDn always scroll chat history; ↑/↓-on-empty-buffer also
|
||||
// routes here via PromptInput's chatScrollHandoff. WT translates wheel
|
||||
// events to ↑/↓ in raw mode, so this is what makes wheel-scroll work.
|
||||
useKeystroke((ev) => {
|
||||
if (ev.pageUp) chatScroll.scrollUp();
|
||||
else if (ev.pageDown) chatScroll.scrollDown();
|
||||
else if (ev.end) chatScroll.jumpToBottom();
|
||||
// Wheel-translated ↑/↓ has nowhere to go when PromptInput can't
|
||||
// process it: in reading mode (unmounted) or while busy (disabled).
|
||||
// When pinned + idle, PromptInput owns arrows for cursor / handoff.
|
||||
else if ((!chatScroll.pinned || busy) && ev.upArrow) chatScroll.scrollUp();
|
||||
else if ((!chatScroll.pinned || busy) && ev.downArrow) chatScroll.scrollDown();
|
||||
}, !modalOpen);
|
||||
|
||||
// Esc during busy → forward to the loop as an abort signal. The loop
|
||||
// finishes the tool call in flight (we can't kill subprocess stdio
|
||||
// mid-write), then diverts to its no-tools summary path so the user
|
||||
@@ -1220,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;
|
||||
}
|
||||
@@ -1237,6 +1284,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -1252,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. 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;
|
||||
}
|
||||
@@ -1271,6 +1319,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -1287,6 +1336,52 @@ function AppInner({
|
||||
log.pushInfo(out);
|
||||
return;
|
||||
}
|
||||
// Space toggles pause on the active undo countdown. Same gating as
|
||||
// the `u` keybind so typing in the prompt isn't intercepted.
|
||||
if (
|
||||
codeMode &&
|
||||
input.length === 0 &&
|
||||
chKey === " " &&
|
||||
undoBanner &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!walkthroughActive &&
|
||||
!pendingChoice &&
|
||||
!stagedChoiceCustom &&
|
||||
!pendingRevision
|
||||
) {
|
||||
toggleUndoPause();
|
||||
return;
|
||||
}
|
||||
// Ctrl-O toggles full-tail view on the live streaming reply so a long
|
||||
// plan / todo can be read while it's still being written. Resets at
|
||||
// turn end so each new reply starts collapsed.
|
||||
if (
|
||||
key.ctrl &&
|
||||
key.input === "o" &&
|
||||
isStreaming &&
|
||||
!pendingShell &&
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
!walkthroughActive &&
|
||||
!pendingChoice &&
|
||||
!stagedChoiceCustom &&
|
||||
!pendingRevision
|
||||
) {
|
||||
setLiveExpand((v) => !v);
|
||||
return;
|
||||
}
|
||||
if (busy) return;
|
||||
// ShellConfirm owns the full keyboard while it's showing. If we
|
||||
// kept handling ↑/↓ / Tab here they'd race with its SingleSelect
|
||||
@@ -1456,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"
|
||||
@@ -1574,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);
|
||||
@@ -1581,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),
|
||||
@@ -1685,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) => {
|
||||
@@ -1736,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({
|
||||
@@ -1777,6 +1904,9 @@ function AppInner({
|
||||
pendingRevision,
|
||||
agentStore,
|
||||
mcpRuntime,
|
||||
getLoopStatus,
|
||||
startLoop,
|
||||
stopLoop,
|
||||
]);
|
||||
|
||||
const stopDashboard = useCallback(async (): Promise<void> => {
|
||||
@@ -1789,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 => {
|
||||
@@ -1818,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]);
|
||||
|
||||
@@ -1862,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;
|
||||
}
|
||||
@@ -1960,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;
|
||||
}
|
||||
@@ -2000,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);
|
||||
}
|
||||
@@ -2095,16 +2223,48 @@ 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);
|
||||
return;
|
||||
}
|
||||
if (result.openModelPicker) {
|
||||
setPendingModelPicker(true);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openArgPickerFor) {
|
||||
pushHistory(text);
|
||||
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),
|
||||
@@ -2138,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;
|
||||
}
|
||||
@@ -2201,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) {
|
||||
@@ -2233,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
|
||||
@@ -2262,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2359,7 +2519,18 @@ function AppInner({
|
||||
const m = loadSessionMeta(session);
|
||||
const cost = (m.totalCostUsd ?? 0) + (ev.stats?.cost ?? 0);
|
||||
const turn = (m.turnCount ?? 0) + 1;
|
||||
patchSessionMeta(session, { totalCostUsd: cost, turnCount: turn });
|
||||
const currency = walletCurrencyRef.current;
|
||||
const u = ev.stats?.usage;
|
||||
const cacheHitTokens = (m.cacheHitTokens ?? 0) + (u?.promptCacheHitTokens ?? 0);
|
||||
const cacheMissTokens = (m.cacheMissTokens ?? 0) + (u?.promptCacheMissTokens ?? 0);
|
||||
patchSessionMeta(session, {
|
||||
totalCostUsd: cost,
|
||||
turnCount: turn,
|
||||
cacheHitTokens,
|
||||
cacheMissTokens,
|
||||
...(u?.promptTokens ? { lastPromptTokens: u.promptTokens } : {}),
|
||||
...(currency ? { balanceCurrency: currency } : {}),
|
||||
});
|
||||
}
|
||||
} else if (ev.role === "tool_start") {
|
||||
handleToolStart(ev, {
|
||||
@@ -2415,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 {
|
||||
@@ -2527,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" });
|
||||
}
|
||||
@@ -2875,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 */
|
||||
@@ -2891,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],
|
||||
@@ -2923,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],
|
||||
@@ -3003,55 +3183,25 @@ function AppInner({
|
||||
[],
|
||||
);
|
||||
|
||||
const modalOpen =
|
||||
!!pendingShell ||
|
||||
!!pendingPlan ||
|
||||
!!pendingReviseEditor ||
|
||||
!!pendingSessionsPicker ||
|
||||
!!pendingMcpHub ||
|
||||
!!stagedInput ||
|
||||
!!pendingEditReview ||
|
||||
walkthroughActive ||
|
||||
!!pendingChoice ||
|
||||
!!stagedChoiceCustom ||
|
||||
!!pendingRevision ||
|
||||
!!stagedCheckpointRevise ||
|
||||
!!pendingCheckpoint;
|
||||
// Suspend cosmetic animations during modal interactions and idle so
|
||||
// a quiescent TUI is byte-stable. PLAIN_UI is the env-flag opt-out
|
||||
// for fragile terminals.
|
||||
const tickerSuspended = PLAIN_UI || modalOpen || (!busy && !isStreaming);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TickerProvider
|
||||
disabled={
|
||||
PLAIN_UI ||
|
||||
isResizing ||
|
||||
!!pendingPlan ||
|
||||
!!pendingReviseEditor ||
|
||||
pendingSessionsPicker ||
|
||||
!!pendingMcpHub ||
|
||||
!!pendingShell ||
|
||||
!!pendingEditReview ||
|
||||
walkthroughActive ||
|
||||
!!pendingChoice ||
|
||||
!!stagedChoiceCustom ||
|
||||
!!pendingRevision ||
|
||||
!!stagedCheckpointRevise ||
|
||||
!!pendingCheckpoint ||
|
||||
// Idle gate: when nothing is actively happening, suspend the
|
||||
// 8Hz/1Hz heartbeats. The cursor blink, gradient pulse, and
|
||||
// spinner glyphs are pure cosmetics — running them at idle
|
||||
// forces Ink to repaint the screen ~8x/sec, which erases any
|
||||
// text selection the user has made in the terminal. With the
|
||||
// ticker paused, an idle TUI is byte-stable and shift-drag /
|
||||
// click-drag selections survive until something actually
|
||||
// changes (incoming stream, key press, modal popup).
|
||||
(!busy && !isStreaming)
|
||||
}
|
||||
>
|
||||
<TickerProvider disabled={tickerSuspended}>
|
||||
<ViewportBudgetProvider>
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="row" height={stdout?.rows ?? 24}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box flexDirection="column">
|
||||
<CardStream suppressLive={modalOpen} />
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<LiveExpandContext.Provider value={liveExpand}>
|
||||
<CardStream
|
||||
suppressLive={modalOpen}
|
||||
scrollRows={chatScroll.scrollRows}
|
||||
onMaxScrollChange={chatScroll.setMaxScroll}
|
||||
/>
|
||||
</LiveExpandContext.Provider>
|
||||
{/*
|
||||
Welcome card on the empty state. Visible only when nothing
|
||||
has happened yet (no past events, nothing in flight, no
|
||||
@@ -3061,6 +3211,7 @@ function AppInner({
|
||||
{!hasConversation && !busy && !isStreaming ? (
|
||||
<WelcomeBanner
|
||||
inCodeMode={!!codeMode}
|
||||
workspaceRoot={codeMode ? currentRootDir : undefined}
|
||||
dashboardUrl={dashboardUrl}
|
||||
languageVersion={languageVersion}
|
||||
/>
|
||||
@@ -3077,6 +3228,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3088,6 +3240,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3099,6 +3252,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3112,6 +3266,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3135,6 +3290,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
@@ -3149,6 +3305,7 @@ function AppInner({
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview ? (
|
||||
@@ -3201,10 +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);
|
||||
@@ -3243,11 +3446,26 @@ function AppInner({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : pendingModelPicker ? (
|
||||
<ModelPicker
|
||||
models={models}
|
||||
current={loop.model}
|
||||
onRefresh={refreshModels}
|
||||
onChoose={(outcome) => {
|
||||
setPendingModelPicker(false);
|
||||
if (outcome.kind === "select") {
|
||||
loop.configure({ model: outcome.id });
|
||||
agentStore.dispatch({ type: "session.model.change", model: outcome.id });
|
||||
log.pushInfo(`▸ model: ${outcome.id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : pendingMcpHub ? (
|
||||
<McpHub
|
||||
initialTab={pendingMcpHub.tab}
|
||||
liveServers={liveMcpServers}
|
||||
configPath={defaultConfigPath()}
|
||||
pickerPorts={pickerPorts}
|
||||
onClose={() => setPendingMcpHub(null)}
|
||||
postInfo={(text) => log.pushInfo(text)}
|
||||
applyAppend={(target, addedTools) => {
|
||||
@@ -3319,6 +3537,10 @@ function AppInner({
|
||||
block={pendingEdits.current[0]!}
|
||||
onChoose={handleWalkChoice}
|
||||
/>
|
||||
) : !chatScroll.pinned ? (
|
||||
<Text color={FG.faint}>
|
||||
{" 📖 reading history — End / PgDn to return · ↓ to advance one line"}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{codeMode ? (
|
||||
@@ -3340,6 +3562,8 @@ function AppInner({
|
||||
disabled={busy}
|
||||
onHistoryPrev={recallPrev}
|
||||
onHistoryNext={recallNext}
|
||||
onChatScrollUp={chatScroll.scrollUp}
|
||||
onChatScrollDown={chatScroll.scrollDown}
|
||||
/>
|
||||
{slashMatches !== null ? (
|
||||
<SlashSuggestions matches={slashMatches} selectedIndex={slashSelected} />
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useMemo, useState } from "react";
|
||||
import type { CheckpointMeta } from "../../code/checkpoints.js";
|
||||
import { fmtAgo } from "../../code/checkpoints.js";
|
||||
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type CheckpointPickerOutcome =
|
||||
| { kind: "restore"; id: string }
|
||||
| { kind: "delete"; id: string }
|
||||
| { kind: "quit" };
|
||||
|
||||
export interface CheckpointPickerProps {
|
||||
checkpoints: ReadonlyArray<CheckpointMeta>;
|
||||
workspace: string;
|
||||
onChoose: (outcome: CheckpointPickerOutcome) => void;
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 6;
|
||||
|
||||
export function CheckpointPicker({
|
||||
checkpoints,
|
||||
workspace,
|
||||
onChoose,
|
||||
pickerPorts,
|
||||
}: CheckpointPickerProps): React.ReactElement {
|
||||
const [focus, setFocus] = useState(0);
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
|
||||
|
||||
const snapshot = useMemo(
|
||||
() => ({
|
||||
pickerKind: "checkpoints",
|
||||
title: `restore a checkpoint — ${workspace}`,
|
||||
items: checkpoints.map((c) => {
|
||||
const sizeKb = (c.bytes / 1024).toFixed(1);
|
||||
const tag = c.source === "manual" ? "" : ` (${c.source})`;
|
||||
return {
|
||||
id: c.id,
|
||||
title: `${c.name}${tag}`,
|
||||
subtitle: `${c.fileCount} file${c.fileCount === 1 ? "" : "s"} · ${sizeKb} KB`,
|
||||
badge: c.id.slice(0, 7),
|
||||
meta: fmtAgo(c.createdAt),
|
||||
};
|
||||
}),
|
||||
actions: ["pick", "delete", "cancel"] as const,
|
||||
hint: "↑↓ pick · ⏎ restore · [d] forget · esc quit",
|
||||
}),
|
||||
[checkpoints, workspace],
|
||||
);
|
||||
|
||||
usePickerBroadcast(
|
||||
!!pickerPorts,
|
||||
{
|
||||
...snapshot,
|
||||
actions: [...snapshot.actions],
|
||||
},
|
||||
(res) => {
|
||||
if (res.action === "pick") return onChoose({ kind: "restore", id: res.id });
|
||||
if (res.action === "delete") return onChoose({ kind: "delete", id: res.id });
|
||||
if (res.action === "cancel") return onChoose({ kind: "quit" });
|
||||
},
|
||||
pickerPorts ?? {
|
||||
broadcast: () => undefined,
|
||||
resolverRef: { current: null },
|
||||
snapshotRef: { current: null },
|
||||
},
|
||||
);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.escape) return onChoose({ kind: "quit" });
|
||||
if (ev.upArrow) return setFocus((f) => Math.max(0, f - 1));
|
||||
if (ev.downArrow) return setFocus((f) => Math.min(checkpoints.length - 1, f + 1));
|
||||
if (checkpoints.length === 0) {
|
||||
if (ev.return) return onChoose({ kind: "quit" });
|
||||
return;
|
||||
}
|
||||
const target = checkpoints[focus];
|
||||
if (!target) return;
|
||||
if (ev.return) return onChoose({ kind: "restore", id: target.id });
|
||||
if (ev.input === "q") return onChoose({ kind: "quit" });
|
||||
if (ev.input === "d") return onChoose({ kind: "delete", id: target.id });
|
||||
});
|
||||
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(focus - Math.floor(visibleCount / 2), checkpoints.length - visibleCount),
|
||||
);
|
||||
const end = Math.min(checkpoints.length, start + visibleCount);
|
||||
const shown = checkpoints.slice(start, end);
|
||||
const hiddenBelow = checkpoints.length - end;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text bold color={TONE.brand}>
|
||||
{" ◈ REASONIX · pick a checkpoint "}
|
||||
</Text>
|
||||
<Text color={FG.meta}>{` · ${workspace}`}</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
{checkpoints.length === 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>
|
||||
{" no checkpoints in this workspace yet — see /checkpoint to make one"}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
shown.map((c, i) => <CheckpointRow key={c.id} info={c} focused={start + i === focus} />)
|
||||
)}
|
||||
{hiddenBelow > 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>
|
||||
{checkpoints.length === 0
|
||||
? " esc quit"
|
||||
: " ↑↓ pick · ⏎ restore · [d] forget · esc quit"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckpointRow({
|
||||
info,
|
||||
focused,
|
||||
}: {
|
||||
info: CheckpointMeta;
|
||||
focused: boolean;
|
||||
}): React.ReactElement {
|
||||
const tag = info.source === "manual" ? "" : ` (${info.source})`;
|
||||
const sizeKb = (info.bytes / 1024).toFixed(1);
|
||||
const time = fmtAgo(info.createdAt);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
|
||||
<Text color={FG.meta}>{info.id.slice(0, 7).padEnd(8)}</Text>
|
||||
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
|
||||
{info.name}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{tag}</Text>
|
||||
<Box flexGrow={1} />
|
||||
<Text color={FG.faint}>{`${time.padStart(8)} · `}</Text>
|
||||
<Text
|
||||
color={FG.faint}
|
||||
>{`${info.fileCount} file${info.fileCount === 1 ? "" : "s"}, ${sizeKb} KB`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Modal picker for `ask_choice` — options + optional "type my own" escape hatch. */
|
||||
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import type { ChoiceOption } from "../../tools/choice.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** `/mcp` browser modal — keyboard-driven server list per design §24. */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { toggleMcpDisabled } from "./mcp-disable.js";
|
||||
|
||||
+10
-2
@@ -1,10 +1,10 @@
|
||||
/** `/mcp` slash modal — single hub with two tabs: Live (attached servers) + Marketplace (registry). */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import { McpBrowser } from "./McpBrowser.js";
|
||||
import { McpMarketplace } from "./McpMarketplace.js";
|
||||
import type { PickerBroadcastPorts } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import type { ApplyAppend } from "./mcp-reconnect-kickoff.js";
|
||||
import type { McpServerSummary } from "./slash/types.js";
|
||||
@@ -24,6 +24,8 @@ export interface McpHubProps {
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
}>;
|
||||
/** Forwarded to the marketplace tab so the web dashboard can drive install / uninstall / refine / load-more. */
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
export function McpHub({
|
||||
@@ -34,6 +36,7 @@ export function McpHub({
|
||||
postInfo,
|
||||
applyAppend,
|
||||
reloadMcp,
|
||||
pickerPorts,
|
||||
}: McpHubProps) {
|
||||
const [tab, setTab] = useState<McpHubTab>(initialTab);
|
||||
|
||||
@@ -65,7 +68,12 @@ export function McpHub({
|
||||
applyAppend={applyAppend}
|
||||
/>
|
||||
) : (
|
||||
<McpMarketplace onClose={onClose} postInfo={postInfo} reloadMcp={reloadMcp} />
|
||||
<McpMarketplace
|
||||
onClose={onClose}
|
||||
postInfo={postInfo}
|
||||
reloadMcp={reloadMcp}
|
||||
pickerPorts={pickerPorts}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
+120
-28
@@ -1,7 +1,6 @@
|
||||
/** `/mcp browse` modal — registry marketplace inside the chat session. */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
specStringFor,
|
||||
} from "../../mcp/registry-fetch.js";
|
||||
import type { RegistryEntry } from "../../mcp/registry-types.js";
|
||||
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { COLOR } from "./theme.js";
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface McpMarketplaceProps {
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
}>;
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -39,6 +40,39 @@ interface State {
|
||||
installedSpecs: string[];
|
||||
}
|
||||
|
||||
export function buildMarketplacePickerSnapshot(args: {
|
||||
filtered: RegistryEntry[];
|
||||
installedSpecs: string[];
|
||||
query: string;
|
||||
status: string;
|
||||
hasMore: boolean;
|
||||
}) {
|
||||
return {
|
||||
pickerKind: "mcp-marketplace" as const,
|
||||
title: `MCP marketplace · ${args.status}`,
|
||||
query: args.query,
|
||||
items: args.filtered.map((e) => {
|
||||
const installedSpec = isInstalled(args.installedSpecs, e);
|
||||
return {
|
||||
id: e.name,
|
||||
title: e.title || e.name,
|
||||
subtitle: e.description?.slice(0, 200) ?? undefined,
|
||||
badge: installedSpec
|
||||
? "installed"
|
||||
: e.source === "official"
|
||||
? "official"
|
||||
: e.source === "smithery"
|
||||
? "smithery"
|
||||
: "local",
|
||||
meta: e.popularity !== undefined ? `★ ${e.popularity.toLocaleString()}` : undefined,
|
||||
};
|
||||
}),
|
||||
actions: ["install", "uninstall", "refine", "load-more", "cancel"] as const,
|
||||
hasMore: args.hasMore,
|
||||
hint: "type filter · ↑↓ pick · ⏎ install/toggle · PgDn load more · esc close",
|
||||
};
|
||||
}
|
||||
|
||||
function rankAndFilter(entries: RegistryEntry[], query: string): RegistryEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q
|
||||
@@ -66,7 +100,7 @@ function isInstalled(installedSpecs: string[], entry: RegistryEntry): string | n
|
||||
}
|
||||
}
|
||||
|
||||
export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceProps) {
|
||||
export function McpMarketplace({ onClose, postInfo, reloadMcp, pickerPorts }: McpMarketplaceProps) {
|
||||
const [state, setState] = useState<State>({
|
||||
handle: null,
|
||||
loading: true,
|
||||
@@ -128,33 +162,30 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
|
||||
}
|
||||
}, [state.handle, state.loading]);
|
||||
|
||||
const installOrToggle = useCallback(
|
||||
async (entry: RegistryEntry) => {
|
||||
const installed = isInstalled(state.installedSpecs, entry);
|
||||
if (installed) {
|
||||
const cfg = readConfig();
|
||||
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
|
||||
writeConfig({ ...cfg, mcp: next });
|
||||
setState((s) => ({
|
||||
...s,
|
||||
installedSpecs: next,
|
||||
status: `uninstalled ${entry.name}`,
|
||||
}));
|
||||
if (reloadMcp) {
|
||||
try {
|
||||
await reloadMcp();
|
||||
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
|
||||
} catch (err) {
|
||||
postInfo(
|
||||
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
|
||||
const doUninstall = useCallback(
|
||||
async (entry: RegistryEntry, installed: string) => {
|
||||
const cfg = readConfig();
|
||||
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
|
||||
writeConfig({ ...cfg, mcp: next });
|
||||
setState((s) => ({ ...s, installedSpecs: next, status: `uninstalled ${entry.name}` }));
|
||||
if (reloadMcp) {
|
||||
try {
|
||||
await reloadMcp();
|
||||
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
|
||||
} catch (err) {
|
||||
postInfo(
|
||||
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
|
||||
}
|
||||
},
|
||||
[postInfo, reloadMcp],
|
||||
);
|
||||
|
||||
const doInstall = useCallback(
|
||||
async (entry: RegistryEntry) => {
|
||||
let install = entry.install;
|
||||
if (!install && entry.source === "smithery") {
|
||||
setState((s) => ({ ...s, loading: true, status: "fetching smithery detail…" }));
|
||||
@@ -176,7 +207,6 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = specStringFor(entry.name, install);
|
||||
const cfg = readConfig();
|
||||
@@ -216,7 +246,69 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
|
||||
setState((s) => ({ ...s, status: `install failed: ${(err as Error).message}` }));
|
||||
}
|
||||
},
|
||||
[state.installedSpecs, postInfo, reloadMcp],
|
||||
[postInfo, reloadMcp],
|
||||
);
|
||||
|
||||
const installOrToggle = useCallback(
|
||||
async (entry: RegistryEntry) => {
|
||||
const installed = isInstalled(state.installedSpecs, entry);
|
||||
if (installed) await doUninstall(entry, installed);
|
||||
else await doInstall(entry);
|
||||
},
|
||||
[state.installedSpecs, doInstall, doUninstall],
|
||||
);
|
||||
|
||||
const pickerSnapshot = useMemo(
|
||||
() =>
|
||||
buildMarketplacePickerSnapshot({
|
||||
filtered,
|
||||
installedSpecs: state.installedSpecs,
|
||||
query: state.query,
|
||||
status: state.status,
|
||||
hasMore: state.handle?.cache.pagination.nextCursor != null,
|
||||
}),
|
||||
[filtered, state.installedSpecs, state.handle, state.query, state.status],
|
||||
);
|
||||
|
||||
usePickerBroadcast(
|
||||
!!pickerPorts,
|
||||
{ ...pickerSnapshot, actions: [...pickerSnapshot.actions] },
|
||||
(res) => {
|
||||
if (res.action === "cancel") return onClose();
|
||||
if (res.action === "refine") {
|
||||
setState((s) => ({ ...s, query: res.query, selected: 0 }));
|
||||
return;
|
||||
}
|
||||
if (res.action === "load-more") {
|
||||
void fetchMore();
|
||||
return;
|
||||
}
|
||||
if (res.action === "install") {
|
||||
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
|
||||
if (!entry) return;
|
||||
if (isInstalled(state.installedSpecs, entry)) {
|
||||
setState((s) => ({ ...s, status: `already installed: ${entry.name}` }));
|
||||
return;
|
||||
}
|
||||
void doInstall(entry);
|
||||
return;
|
||||
}
|
||||
if (res.action === "uninstall") {
|
||||
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
|
||||
if (!entry) return;
|
||||
const installed = isInstalled(state.installedSpecs, entry);
|
||||
if (!installed) {
|
||||
setState((s) => ({ ...s, status: `not installed: ${entry.name}` }));
|
||||
return;
|
||||
}
|
||||
void doUninstall(entry, installed);
|
||||
}
|
||||
},
|
||||
pickerPorts ?? {
|
||||
broadcast: () => undefined,
|
||||
resolverRef: { current: null },
|
||||
snapshotRef: { current: null },
|
||||
},
|
||||
);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { PILL_MODEL, Pill, modelBadgeFor } from "./primitives/Pill.js";
|
||||
import { FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type ModelPickerOutcome = { kind: "select"; id: string } | { kind: "quit" };
|
||||
|
||||
export interface ModelPickerProps {
|
||||
/** API-fetched ids; null means "still loading / offline". */
|
||||
models: ReadonlyArray<string> | null;
|
||||
/** Model id currently active in the loop — marked with the cursor on open. */
|
||||
current: string;
|
||||
onChoose: (outcome: ModelPickerOutcome) => void;
|
||||
/** Triggers a refetch when the catalog is null/empty and the user presses [r]. */
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 6;
|
||||
|
||||
export function ModelPicker({
|
||||
models,
|
||||
current,
|
||||
onChoose,
|
||||
onRefresh,
|
||||
}: ModelPickerProps): React.ReactElement {
|
||||
const list = (models && models.length > 0 ? models : FALLBACK_MODELS).slice();
|
||||
if (!list.includes(current)) list.unshift(current);
|
||||
const initialIndex = Math.max(0, list.indexOf(current));
|
||||
const [focus, setFocus] = useState(initialIndex);
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.escape) return onChoose({ kind: "quit" });
|
||||
if (ev.upArrow) return setFocus((f) => Math.max(0, f - 1));
|
||||
if (ev.downArrow) return setFocus((f) => Math.min(list.length - 1, f + 1));
|
||||
if (ev.return) {
|
||||
const target = list[focus];
|
||||
if (target) onChoose({ kind: "select", id: target });
|
||||
return;
|
||||
}
|
||||
if (!ev.input) return;
|
||||
if (ev.input === "q") return onChoose({ kind: "quit" });
|
||||
if (ev.input === "r") onRefresh?.();
|
||||
});
|
||||
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(focus - Math.floor(visibleCount / 2), list.length - visibleCount),
|
||||
);
|
||||
const end = Math.min(list.length, start + visibleCount);
|
||||
const shown = list.slice(start, end);
|
||||
const hiddenAbove = start;
|
||||
const hiddenBelow = list.length - end;
|
||||
const loading = models === null;
|
||||
const empty = models !== null && models.length === 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text bold color={TONE.brand}>
|
||||
{" ◈ REASONIX · pick a model "}
|
||||
</Text>
|
||||
<Text color={FG.meta}>
|
||||
{loading
|
||||
? " · loading catalog…"
|
||||
: empty
|
||||
? " · catalog empty — using known fallbacks"
|
||||
: ` · ${list.length} available`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
{hiddenAbove > 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` … ${hiddenAbove} earlier`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{shown.map((id, i) => (
|
||||
<ModelRow key={id} id={id} focused={start + i === focus} active={id === current} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>{" ↑↓ pick · ⏎ confirm · [r] refresh · esc cancel"}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
id,
|
||||
focused,
|
||||
active,
|
||||
}: {
|
||||
id: string;
|
||||
focused: boolean;
|
||||
active: boolean;
|
||||
}): React.ReactElement {
|
||||
const badge = modelBadgeFor(id);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
|
||||
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
|
||||
{id.padEnd(24)}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Pill label={badge.label} {...PILL_MODEL[badge.kind]} bold={false} />
|
||||
{active ? <Text color={TONE.brand}>{" · current"}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hard-coded known DeepSeek ids — used when the API catalog hasn't loaded yet so the picker isn't empty on first open. */
|
||||
const FALLBACK_MODELS: ReadonlyArray<string> = [
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
];
|
||||
@@ -1,7 +1,6 @@
|
||||
/** Modal picker for `PlanCheckpointError`: continue / revise / stop. */
|
||||
|
||||
import { Box } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** Modal-style picker for `submit_plan`: accept / refine / cancel. */
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { PlanStepList } from "./PlanStepList.js";
|
||||
|
||||
+31
-17
@@ -1,7 +1,6 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform compiles JSX to React.createElement)
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useCursor } from "../../renderer/index.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
import { type MultilineKey, lineAndColumn, processMultilineKey } from "./multiline-keys.js";
|
||||
@@ -20,20 +19,25 @@ import { FG, SURFACE, TONE } from "./theme/tokens.js";
|
||||
|
||||
/** Raw-stdin keystroke bus → multiline reducer; one logical line per Box row, viewport-clipped. */
|
||||
|
||||
/** Pastes shorter than this AND single-line render verbatim; longer ones become a `[paste #N · …]` sentinel chip (#397). */
|
||||
export const INLINE_PASTE_THRESHOLD = 200;
|
||||
|
||||
export function shouldInlinePaste(content: string): boolean {
|
||||
return !content.includes("\n") && content.length <= INLINE_PASTE_THRESHOLD;
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSubmit: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
/**
|
||||
* History recall. Fired when ↑/↓ hits a buffer boundary (empty
|
||||
* buffer, or cursor already at the first/last line of a multi-line
|
||||
* buffer). Parent walks its prompt history and swaps the value via
|
||||
* `onChange`. Absent → keys are consumed silently.
|
||||
*/
|
||||
/** Ctrl+P / Ctrl+N — parent walks its prompt history and swaps `value` via `onChange`. */
|
||||
onHistoryPrev?: () => void;
|
||||
onHistoryNext?: () => void;
|
||||
/** ↑/↓ on an empty buffer — parent scrolls chat history. */
|
||||
onChatScrollUp?: () => void;
|
||||
onChatScrollDown?: () => void;
|
||||
}
|
||||
|
||||
export function PromptInput({
|
||||
@@ -44,10 +48,16 @@ export function PromptInput({
|
||||
placeholder,
|
||||
onHistoryPrev,
|
||||
onHistoryNext,
|
||||
onChatScrollUp,
|
||||
onChatScrollDown,
|
||||
}: PromptInputProps) {
|
||||
// Cap at 24 — collapseLinesForDisplay hides content past ~20 logical lines.
|
||||
// Quantize spec.max to 4-row buckets so per-keystroke line-count changes
|
||||
// don't churn viewport-budget; without this every single character that
|
||||
// adds/removes a newline re-dispatches the allocator and reflows layout.
|
||||
const inputLineCount = value.length > 0 ? value.split("\n").length : 1;
|
||||
useReserveRows("input", { min: 1, max: Math.min(inputLineCount + 3, 24) });
|
||||
const reserveMax = Math.min(Math.ceil(inputLineCount / 4) * 4 + 3, 24);
|
||||
useReserveRows("input", { min: 1, max: reserveMax });
|
||||
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
|
||||
@@ -71,15 +81,19 @@ export function PromptInput({
|
||||
const registerPaste = (content: string) => {
|
||||
const v = lastLocalValueRef.current;
|
||||
const c = cursorRef.current;
|
||||
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
|
||||
nextPasteIdRef.current = id + 1;
|
||||
pastesRef.current.set(id, makePasteEntry(id, content));
|
||||
const sentinel = encodePasteSentinel(id);
|
||||
const next = v.slice(0, c) + sentinel + v.slice(c);
|
||||
const insertion = shouldInlinePaste(content)
|
||||
? content
|
||||
: (() => {
|
||||
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
|
||||
nextPasteIdRef.current = id + 1;
|
||||
pastesRef.current.set(id, makePasteEntry(id, content));
|
||||
return encodePasteSentinel(id);
|
||||
})();
|
||||
const next = v.slice(0, c) + insertion + v.slice(c);
|
||||
lastLocalValueRef.current = next;
|
||||
cursorRef.current = c + 1;
|
||||
cursorRef.current = c + insertion.length;
|
||||
onChange(next);
|
||||
setCursor(c + 1);
|
||||
setCursor(c + insertion.length);
|
||||
};
|
||||
|
||||
useKeystroke((ev) => {
|
||||
@@ -132,6 +146,8 @@ export function PromptInput({
|
||||
}
|
||||
if (action.historyHandoff === "prev") onHistoryPrev?.();
|
||||
if (action.historyHandoff === "next") onHistoryNext?.();
|
||||
if (action.chatScrollHandoff === "up") onChatScrollUp?.();
|
||||
if (action.chatScrollHandoff === "down") onChatScrollDown?.();
|
||||
}, !disabled);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────
|
||||
@@ -153,8 +169,6 @@ export function PromptInput({
|
||||
const accentColor = disabled ? FG.faint : TONE.brand;
|
||||
const cursorVisible = true;
|
||||
const { line: cursorLine, col: cursorCol } = lineAndColumn(value, cursor);
|
||||
// PromptInput paints its own cursor (`▌` / inverse char) — hide the real terminal cursor so it doesn't double up at the bottom of the screen.
|
||||
useCursor({ col: 0, rowFromBottom: 0, visible: false });
|
||||
|
||||
const renderItems = collapseLinesForDisplay(lines, cursorLine);
|
||||
const showHugeBufferHints = lines.length > 20;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import type { SessionInfo } from "../../memory/session.js";
|
||||
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { FG, TONE, USD_TO_CNY } from "./theme/tokens.js";
|
||||
import { FG, TONE, formatCost } from "./theme/tokens.js";
|
||||
|
||||
export type SessionPickerOutcome =
|
||||
| { kind: "open"; name: string }
|
||||
@@ -16,6 +17,10 @@ export interface SessionPickerProps {
|
||||
sessions: ReadonlyArray<SessionInfo>;
|
||||
workspace: string;
|
||||
onChoose: (outcome: SessionPickerOutcome) => void;
|
||||
/** Live wallet currency from App.tsx; falls back to each session's stored `meta.balanceCurrency` per row. */
|
||||
walletCurrency?: string;
|
||||
/** When provided, broadcasts to the web dashboard so it can resolve via `/api/modal/resolve`. */
|
||||
pickerPorts?: PickerBroadcastPorts;
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 6;
|
||||
@@ -24,6 +29,8 @@ export function SessionPicker({
|
||||
sessions,
|
||||
workspace,
|
||||
onChoose,
|
||||
walletCurrency,
|
||||
pickerPorts,
|
||||
}: SessionPickerProps): React.ReactElement {
|
||||
const [focus, setFocus] = useState(0);
|
||||
const [renaming, setRenaming] = useState<{ from: string; buf: string } | null>(null);
|
||||
@@ -31,6 +38,53 @@ export function SessionPicker({
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
|
||||
|
||||
const snapshot = useMemo(
|
||||
() => ({
|
||||
pickerKind: "sessions",
|
||||
title: `pick a session — ${workspace}`,
|
||||
items: sessions.map((s) => {
|
||||
const branch = s.meta.branch ?? "main";
|
||||
const summary =
|
||||
s.meta.summary ?? `${s.messageCount} message${s.messageCount === 1 ? "" : "s"}`;
|
||||
const turns = s.meta.turnCount ?? Math.ceil(s.messageCount / 2);
|
||||
const currency = walletCurrency ?? s.meta.balanceCurrency;
|
||||
const costLabel =
|
||||
s.meta.totalCostUsd !== undefined ? formatCost(s.meta.totalCostUsd, currency, 2) : "";
|
||||
return {
|
||||
id: s.name,
|
||||
title: s.name,
|
||||
subtitle: summary,
|
||||
badge: branch,
|
||||
meta: costLabel ? `${turns} turns · ${costLabel}` : `${turns} turns`,
|
||||
};
|
||||
}),
|
||||
actions: ["pick", "delete", "rename", "new", "cancel"] as const,
|
||||
hint: "↑↓ pick · ⏎ open · [n] new · [d] delete · [r] rename · esc quit",
|
||||
}),
|
||||
[sessions, workspace, walletCurrency],
|
||||
);
|
||||
|
||||
usePickerBroadcast(
|
||||
!!pickerPorts,
|
||||
{
|
||||
...snapshot,
|
||||
actions: [...snapshot.actions],
|
||||
},
|
||||
(res) => {
|
||||
if (res.action === "pick") return onChoose({ kind: "open", name: res.id });
|
||||
if (res.action === "delete") return onChoose({ kind: "delete", name: res.id });
|
||||
if (res.action === "rename")
|
||||
return onChoose({ kind: "rename", name: res.id, newName: res.text });
|
||||
if (res.action === "new") return onChoose({ kind: "new" });
|
||||
if (res.action === "cancel") return onChoose({ kind: "quit" });
|
||||
},
|
||||
pickerPorts ?? {
|
||||
broadcast: () => undefined,
|
||||
resolverRef: { current: null },
|
||||
snapshotRef: { current: null },
|
||||
},
|
||||
);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.paste) {
|
||||
if (renaming) setRenaming({ ...renaming, buf: renaming.buf + ev.input });
|
||||
@@ -101,7 +155,14 @@ export function SessionPicker({
|
||||
<Text color={FG.faint}>{" to start a new one"}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
shown.map((s, i) => <SessionRow key={s.name} info={s} focused={start + i === focus} />)
|
||||
shown.map((s, i) => (
|
||||
<SessionRow
|
||||
key={s.name}
|
||||
info={s}
|
||||
focused={start + i === focus}
|
||||
walletCurrency={walletCurrency}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{hiddenBelow > 0 ? (
|
||||
<Box>
|
||||
@@ -135,15 +196,19 @@ export function SessionPicker({
|
||||
function SessionRow({
|
||||
info,
|
||||
focused,
|
||||
}: { info: SessionInfo; focused: boolean }): React.ReactElement {
|
||||
walletCurrency,
|
||||
}: {
|
||||
info: SessionInfo;
|
||||
focused: boolean;
|
||||
walletCurrency?: string;
|
||||
}): React.ReactElement {
|
||||
const branch = info.meta.branch ?? "main";
|
||||
const summary =
|
||||
info.meta.summary ?? `${info.messageCount} message${info.messageCount === 1 ? "" : "s"}`;
|
||||
const turns = info.meta.turnCount ?? Math.ceil(info.messageCount / 2);
|
||||
const costCny =
|
||||
info.meta.totalCostUsd !== undefined
|
||||
? `¥${(info.meta.totalCostUsd * USD_TO_CNY).toFixed(2)}`
|
||||
: "";
|
||||
const currency = walletCurrency ?? info.meta.balanceCurrency;
|
||||
const costLabel =
|
||||
info.meta.totalCostUsd !== undefined ? formatCost(info.meta.totalCostUsd, currency, 2) : "";
|
||||
const time = relativeTime(info.mtime);
|
||||
return (
|
||||
<Box>
|
||||
@@ -156,7 +221,7 @@ function SessionRow({
|
||||
<Box flexGrow={1} />
|
||||
<Text color={FG.faint}>{`${time.padStart(11)} `}</Text>
|
||||
<Text color={FG.faint}>{`${turns} turns`}</Text>
|
||||
{costCny ? <Text color={FG.faint}>{` · ${costCny}`}</Text> : null}
|
||||
{costLabel ? <Text color={FG.faint}>{` · ${costLabel}`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useState } from "react";
|
||||
import { DenyContextInput } from "./DenyContextInput.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
|
||||
@@ -9,6 +9,8 @@ import { FG, TONE } from "./theme/tokens.js";
|
||||
export interface WelcomeBannerProps {
|
||||
/** True when running `reasonix code`. Surfaces code-mode hints. */
|
||||
inCodeMode?: boolean;
|
||||
/** Pinned workspace root — only meaningful in code mode. Surfaced so first-time users see they can pass --dir at next launch. */
|
||||
workspaceRoot?: string;
|
||||
/** Live URL of the embedded dashboard, or null when it isn't running. */
|
||||
dashboardUrl?: string | null;
|
||||
/** Bumped on language change; forces re-render so t() picks up new locale. */
|
||||
@@ -19,6 +21,7 @@ const HINTS = ["/help", "/init", "/memory", "/cost"] as const;
|
||||
|
||||
export function WelcomeBanner({
|
||||
inCodeMode,
|
||||
workspaceRoot,
|
||||
dashboardUrl,
|
||||
}: WelcomeBannerProps): React.ReactElement {
|
||||
const tagline = inCodeMode ? t("ui.taglineCode") : t("ui.taglineChat");
|
||||
@@ -66,6 +69,15 @@ export function WelcomeBanner({
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{inCodeMode && workspaceRoot ? (
|
||||
<Box marginTop={1} flexDirection="row" gap={1}>
|
||||
<Text color={TONE.brand}>{"▸ workspace"}</Text>
|
||||
<Text color={FG.faint}>{"·"}</Text>
|
||||
<Text color={FG.body}>{workspaceRoot}</Text>
|
||||
<Text color={FG.faint}>{" (relaunch with --dir <path> to switch)"}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{dashboardUrl ? (
|
||||
<Box marginTop={1} flexDirection="row" gap={1}>
|
||||
<Text color={TONE.brand} bold>
|
||||
|
||||
+176
-54
@@ -1,20 +1,17 @@
|
||||
/**
|
||||
* First-run / re-configure wizard.
|
||||
*
|
||||
* Walks a new user through: API key → preset pick → MCP server pick →
|
||||
* per-server args → save. Saved output lives in `~/.reasonix/config.json`
|
||||
* so the next `reasonix chat` starts with everything already wired.
|
||||
*
|
||||
* The wizard is the antidote to "too many CLI flags" — a new user should
|
||||
* never have to read `--help` to get MCP + a sensible model combo
|
||||
* working. Everything a user could set via `--mcp`, `--harvest`,
|
||||
* `--branch`, etc. can be picked here in a few arrow-key presses.
|
||||
* Walks a new user through: language → API key → preset pick → MCP server
|
||||
* pick → per-server args → save. Saved output lives in
|
||||
* `~/.reasonix/config.json` so the next `reasonix chat` starts with
|
||||
* everything already wired.
|
||||
*/
|
||||
|
||||
import { mkdirSync, statSync } from "node:fs";
|
||||
import { Box, Text, useApp, useInput } from "ink";
|
||||
import TextInput from "ink-text-input";
|
||||
// biome-ignore lint/style/useImportType: JSX (jsx: "react") needs React as a value at runtime
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type PresetName,
|
||||
type ReasonixConfig,
|
||||
@@ -24,6 +21,16 @@ import {
|
||||
redactKey,
|
||||
writeConfig,
|
||||
} from "../../config.js";
|
||||
import {
|
||||
detectSystemLanguage,
|
||||
getLanguage,
|
||||
getSupportedLanguages,
|
||||
notifyLanguageChange,
|
||||
onLanguageChange,
|
||||
setLanguage,
|
||||
t,
|
||||
} from "../../i18n/index.js";
|
||||
import type { LanguageCode } from "../../i18n/types.js";
|
||||
import { type CatalogEntry, MCP_CATALOG } from "../../mcp/catalog.js";
|
||||
import { MultiSelect, type SelectItem, SingleSelect } from "./Select.js";
|
||||
import { PRESET_DESCRIPTIONS } from "./presets.js";
|
||||
@@ -42,22 +49,31 @@ export interface WizardProps {
|
||||
};
|
||||
}
|
||||
|
||||
type Step = "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
|
||||
type Step = "language" | "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
|
||||
|
||||
interface WizardData {
|
||||
language: LanguageCode;
|
||||
apiKey: string;
|
||||
preset: PresetName;
|
||||
selectedCatalog: string[]; // entries from MCP_CATALOG by `name`
|
||||
/** Captured user inputs per catalog entry that has `userArgs` (e.g. fs dir). */
|
||||
selectedCatalog: string[];
|
||||
catalogArgs: Record<string, string>;
|
||||
}
|
||||
|
||||
const CATALOG_BY_NAME = new Map(MCP_CATALOG.map((e) => [e.name, e]));
|
||||
|
||||
const LANGUAGE_LABELS: Record<LanguageCode, string> = {
|
||||
EN: "English",
|
||||
"zh-CN": "简体中文",
|
||||
};
|
||||
|
||||
export function Wizard({ onComplete, onCancel, existingApiKey, initial }: WizardProps) {
|
||||
const { exit } = useApp();
|
||||
const [step, setStep] = useState<Step>(existingApiKey ? "preset" : "apiKey");
|
||||
const [, setLanguageVersion] = useState(0);
|
||||
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
|
||||
|
||||
const [step, setStep] = useState<Step>("language");
|
||||
const [data, setData] = useState<WizardData>({
|
||||
language: getLanguage(),
|
||||
apiKey: existingApiKey ?? "",
|
||||
preset: initial?.preset ?? "auto",
|
||||
selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
|
||||
@@ -65,12 +81,24 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Global Esc → cancel. Disabled once we've started saving to avoid
|
||||
// ejecting out of a half-written state.
|
||||
useInput((_input, key) => {
|
||||
if (key.escape && step !== "saved" && onCancel) onCancel();
|
||||
});
|
||||
|
||||
if (step === "language") {
|
||||
return (
|
||||
<LanguageStep
|
||||
initialValue={data.language}
|
||||
onSubmit={(lang) => {
|
||||
setLanguage(lang);
|
||||
notifyLanguageChange();
|
||||
setData((d) => ({ ...d, language: lang }));
|
||||
setStep(existingApiKey ? "preset" : "apiKey");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "apiKey") {
|
||||
return (
|
||||
<ApiKeyStep
|
||||
@@ -87,7 +115,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
|
||||
if (step === "preset") {
|
||||
return (
|
||||
<StepFrame title="Pick a preset" step={1} total={3}>
|
||||
<StepFrame title={t("wizard.presetTitle")} step={1} total={3}>
|
||||
<SingleSelect<PresetName>
|
||||
items={presetItems()}
|
||||
initialValue={data.preset}
|
||||
@@ -97,7 +125,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
}}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] navigate · [Enter] confirm · [Esc] cancel</Text>
|
||||
<Text dimColor>{t("wizard.selectFooter")}</Text>
|
||||
</Box>
|
||||
</StepFrame>
|
||||
);
|
||||
@@ -105,17 +133,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
|
||||
if (step === "mcp") {
|
||||
return (
|
||||
<StepFrame title="Which MCP servers should Reasonix wire up for you?" step={2} total={3}>
|
||||
<StepFrame title={t("wizard.mcpTitle")} step={2} total={3}>
|
||||
<MultiSelect
|
||||
items={mcpItems()}
|
||||
initialSelected={data.selectedCatalog}
|
||||
onSubmit={(selected) => {
|
||||
setData((d) => ({ ...d, selectedCatalog: selected }));
|
||||
// Only advance to the args step if any selected entry needs args.
|
||||
const needsArgs = selected.some((name) => CATALOG_BY_NAME.get(name)?.userArgs);
|
||||
setStep(needsArgs ? "mcpArgs" : "review");
|
||||
}}
|
||||
footer="[↑↓] navigate · [Space] toggle · [Enter] confirm · [Esc] cancel · empty = skip"
|
||||
footer={t("wizard.mcpFooterMulti")}
|
||||
/>
|
||||
</StepFrame>
|
||||
);
|
||||
@@ -151,13 +178,21 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
if (step === "review") {
|
||||
const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
|
||||
return (
|
||||
<StepFrame title="Ready to save" step={3} total={3}>
|
||||
<StepFrame title={t("wizard.reviewTitle")} step={3} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<SummaryLine label="API key" value={redactKey(data.apiKey)} />
|
||||
<SummaryLine label="Preset" value={data.preset} />
|
||||
<SummaryLine
|
||||
label="MCP"
|
||||
value={specs.length === 0 ? "(none)" : `${specs.length} server(s)`}
|
||||
label={t("wizard.reviewLabelLanguage")}
|
||||
value={LANGUAGE_LABELS[data.language]}
|
||||
/>
|
||||
<SummaryLine label={t("wizard.reviewLabelApiKey")} value={redactKey(data.apiKey)} />
|
||||
<SummaryLine label={t("wizard.reviewLabelPreset")} value={data.preset} />
|
||||
<SummaryLine
|
||||
label={t("wizard.reviewLabelMcp")}
|
||||
value={
|
||||
specs.length === 0
|
||||
? t("wizard.reviewMcpNone")
|
||||
: t("wizard.reviewMcpServers", { count: specs.length })
|
||||
}
|
||||
/>
|
||||
{specs.map((spec, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: review-only render, order fixed
|
||||
@@ -166,7 +201,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text>Saves to {defaultConfigPath()}</Text>
|
||||
<Text>{t("wizard.reviewSavesTo", { path: defaultConfigPath() })}</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
@@ -174,7 +209,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[Enter] save · [Esc] cancel</Text>
|
||||
<Text dimColor>{t("wizard.reviewFooter")}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<ReviewConfirm
|
||||
@@ -195,7 +230,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
setStep("saved");
|
||||
onComplete(next);
|
||||
} catch (e) {
|
||||
setError(`Could not save config: ${(e as Error).message}`);
|
||||
setError(t("wizard.reviewSaveError", { message: (e as Error).message }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -203,17 +238,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
);
|
||||
}
|
||||
|
||||
// saved
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
|
||||
<Text bold color="green">
|
||||
▸ Saved.
|
||||
{t("wizard.savedTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Run `reasonix` any time to start chatting — your settings are remembered.</Text>
|
||||
<Text>{t("ui.welcome")}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[Enter] to exit</Text>
|
||||
<Text dimColor>{t("wizard.savedFooter")}</Text>
|
||||
</Box>
|
||||
<ExitOnEnter onExit={exit} />
|
||||
</Box>
|
||||
@@ -222,6 +256,38 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
|
||||
|
||||
// ---------- step components ----------
|
||||
|
||||
function LanguageStep({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialValue: LanguageCode;
|
||||
onSubmit: (lang: LanguageCode) => void;
|
||||
}) {
|
||||
const items: SelectItem<LanguageCode>[] = getSupportedLanguages().map((code) => ({
|
||||
value: code,
|
||||
label: LANGUAGE_LABELS[code],
|
||||
hint: code === detectSystemLanguage() ? "(detected)" : undefined,
|
||||
}));
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
{t("wizard.languageTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.languageSubtitle")}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<SingleSelect<LanguageCode>
|
||||
items={items}
|
||||
initialValue={initialValue}
|
||||
onSubmit={onSubmit}
|
||||
footer={t("wizard.selectFooter")}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyStep({
|
||||
onSubmit,
|
||||
error,
|
||||
@@ -235,16 +301,16 @@ function ApiKeyStep({
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyan">
|
||||
Welcome to Reasonix.
|
||||
{t("wizard.welcomeTitle")}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>Paste your DeepSeek API key to get started.</Text>
|
||||
<Text>{t("wizard.apiKeyPrompt")}</Text>
|
||||
</Box>
|
||||
<Text dimColor>Get one at: https://platform.deepseek.com/api_keys</Text>
|
||||
<Text dimColor>Saved locally to {defaultConfigPath()}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeyGetOne")}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeySavedLocally", { path: defaultConfigPath() })}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text bold color="cyan">
|
||||
{"key › "}
|
||||
{t("wizard.apiKeyInputLabel")}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
@@ -252,7 +318,7 @@ function ApiKeyStep({
|
||||
onSubmit={(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!isPlausibleKey(trimmed)) {
|
||||
onError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
|
||||
onError(t("wizard.apiKeyInvalid"));
|
||||
setValue("");
|
||||
return;
|
||||
}
|
||||
@@ -268,7 +334,7 @@ function ApiKeyStep({
|
||||
</Box>
|
||||
) : value ? (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>preview: {redactKey(value)}</Text>
|
||||
<Text dimColor>{t("wizard.apiKeyPreview", { redacted: redactKey(value) })}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -287,8 +353,54 @@ function McpArgsStep({
|
||||
onError: (e: string | null) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [pendingCreate, setPendingCreate] = useState<string | null>(null);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (!pendingCreate) return;
|
||||
const ch = input.toLowerCase();
|
||||
if (ch === "y" || key.return) {
|
||||
try {
|
||||
mkdirSync(pendingCreate, { recursive: true });
|
||||
const created = pendingCreate;
|
||||
setPendingCreate(null);
|
||||
setValue("");
|
||||
onError(null);
|
||||
onSubmit(created);
|
||||
} catch (e) {
|
||||
onError(
|
||||
t("wizard.mcpArgsDirCreateFailed", {
|
||||
path: pendingCreate,
|
||||
message: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
setPendingCreate(null);
|
||||
}
|
||||
} else if (ch === "n" || key.escape) {
|
||||
setPendingCreate(null);
|
||||
onError(null);
|
||||
}
|
||||
});
|
||||
|
||||
if (pendingCreate) {
|
||||
return (
|
||||
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<Text>{t("wizard.mcpArgsDirMissing", { path: pendingCreate })}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t("wizard.mcpArgsDirCreateHint")}</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</StepFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StepFrame title={`Configure ${entry.name}`} step={2} total={3}>
|
||||
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
|
||||
<Box flexDirection="column">
|
||||
<Text>{entry.summary}</Text>
|
||||
{entry.note ? (
|
||||
@@ -297,7 +409,7 @@ function McpArgsStep({
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text>Required parameter: </Text>
|
||||
<Text>{t("wizard.mcpArgsRequiredParam")}</Text>
|
||||
<Text bold>{entry.userArgs}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
@@ -311,9 +423,20 @@ function McpArgsStep({
|
||||
onSubmit={(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
onError(`${entry.name} needs a value — got an empty string.`);
|
||||
onError(t("wizard.mcpArgsEmpty", { name: entry.name }));
|
||||
return;
|
||||
}
|
||||
if (entry.name === "filesystem") {
|
||||
const check = checkFilesystemPath(trimmed);
|
||||
if (check.kind === "missing") {
|
||||
setPendingCreate(trimmed);
|
||||
return;
|
||||
}
|
||||
if (check.kind === "not-a-dir") {
|
||||
onError(t("wizard.mcpArgsNotADir", { path: trimmed }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSubmit(trimmed);
|
||||
setValue("");
|
||||
}}
|
||||
@@ -330,6 +453,14 @@ function McpArgsStep({
|
||||
);
|
||||
}
|
||||
|
||||
function checkFilesystemPath(p: string): { kind: "ok" | "missing" | "not-a-dir" } {
|
||||
try {
|
||||
return { kind: statSync(p).isDirectory() ? "ok" : "not-a-dir" };
|
||||
} catch {
|
||||
return { kind: "missing" };
|
||||
}
|
||||
}
|
||||
|
||||
function ReviewConfirm({ onConfirm }: { onConfirm: () => void }) {
|
||||
useInput((_i, key) => {
|
||||
if (key.return) onConfirm();
|
||||
@@ -344,8 +475,6 @@ function ExitOnEnter({ onExit }: { onExit: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- small bits ----------
|
||||
|
||||
function StepFrame({
|
||||
title,
|
||||
step,
|
||||
@@ -360,9 +489,7 @@ function StepFrame({
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Step {step}/{total} ·{" "}
|
||||
</Text>
|
||||
<Text dimColor>{t("wizard.stepCounter", { step, total })}</Text>
|
||||
<Text bold color="cyan">
|
||||
{title}
|
||||
</Text>
|
||||
@@ -383,8 +510,6 @@ function SummaryLine({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- data helpers ----------
|
||||
|
||||
function presetItems(): SelectItem<PresetName>[] {
|
||||
return (["auto", "flash", "pro"] as const).map((name) => ({
|
||||
value: name as PresetName,
|
||||
@@ -396,7 +521,7 @@ function presetItems(): SelectItem<PresetName>[] {
|
||||
function mcpItems(): SelectItem<string>[] {
|
||||
return MCP_CATALOG.map((entry) => {
|
||||
const hintParts: string[] = [entry.summary];
|
||||
if (entry.userArgs) hintParts.push(`(you'll provide ${entry.userArgs})`);
|
||||
if (entry.userArgs) hintParts.push(t("wizard.mcpUserArgsHint", { arg: entry.userArgs }));
|
||||
if (entry.note) hintParts.push(entry.note);
|
||||
return {
|
||||
value: entry.name,
|
||||
@@ -413,9 +538,6 @@ function placeholderFor(entry: CatalogEntry): string {
|
||||
}
|
||||
|
||||
function deriveInitialCatalog(existingSpecs: string[]): string[] {
|
||||
// Best-effort recovery: if the user previously picked catalog entries,
|
||||
// the spec strings look like `name=npx -y <pkg> ...`. Match by package
|
||||
// so reconfigure pre-checks the same boxes.
|
||||
const packageToName = new Map(MCP_CATALOG.map((e) => [e.package, e.name]));
|
||||
const out: string[] = [];
|
||||
for (const spec of existingSpecs) {
|
||||
@@ -436,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}`;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/** Alt-screen lifecycle — restore must be idempotent and run on SIGINT/SIGTERM/exit so the user's main buffer always comes back. */
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
function isInteractiveTty(): boolean {
|
||||
return Boolean(process.stdout?.isTTY);
|
||||
}
|
||||
|
||||
export function useAltScreen(): void {
|
||||
useEffect(() => {
|
||||
if (!isInteractiveTty()) return;
|
||||
// 1002 = button-event tracking (press + drag + release); 1006 = SGR coords.
|
||||
// Shift+drag bypasses tracking for native terminal selection.
|
||||
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H\x1b[?1002h\x1b[?1006h");
|
||||
|
||||
let restored = false;
|
||||
const restore = () => {
|
||||
if (restored) return;
|
||||
restored = true;
|
||||
try {
|
||||
process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?1049l");
|
||||
} catch {
|
||||
/* terminal closed */
|
||||
}
|
||||
};
|
||||
|
||||
process.once("exit", restore);
|
||||
process.once("SIGINT", () => {
|
||||
restore();
|
||||
process.exit(130);
|
||||
});
|
||||
process.once("SIGTERM", () => {
|
||||
restore();
|
||||
process.exit(143);
|
||||
});
|
||||
|
||||
return restore;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import type { Card } from "../state/cards.js";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
@@ -21,11 +20,14 @@ import { UsageCard } from "./UsageCard.js";
|
||||
import { UserCard } from "./UserCard.js";
|
||||
import { WarnCard } from "./WarnCard.js";
|
||||
|
||||
export function CardRenderer({ card }: { card: Card }): React.ReactElement {
|
||||
// No marginTop here — Card primitive owns inter-card spacing. Doubling it
|
||||
// bakes 2 blank rows into every static-emitted block of bytes.
|
||||
// Memoized so the cards array re-rendering (every store update) only
|
||||
// reconciles cards whose object identity actually changed — the reducer
|
||||
// keeps prior cards reference-stable, so unchanged history skips work.
|
||||
export const CardRenderer = React.memo(function CardRenderer({
|
||||
card,
|
||||
}: { card: Card }): React.ReactElement {
|
||||
return <Box flexDirection="column">{renderCard(card)}</Box>;
|
||||
}
|
||||
});
|
||||
|
||||
function renderCard(card: Card): React.ReactElement {
|
||||
switch (card.kind) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import React, { useContext } from "react";
|
||||
import { clipToCells, wrapToCells } from "../../../frame/width.js";
|
||||
import { countTokens } from "../../../tokenizer.js";
|
||||
import { LiveExpandContext } from "../layout/LiveExpandContext.js";
|
||||
import { useReserveRows } from "../layout/viewport-budget.js";
|
||||
import { Markdown } from "../markdown.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
@@ -10,17 +12,49 @@ import { PILL_MODEL, Pill, modelBadgeFor } from "../primitives/Pill.js";
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import type { StreamingCard as StreamingCardData } from "../state/cards.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
import { useSlowTick } from "../ticker.js";
|
||||
|
||||
/** Streaming preview tail length — bounded live region so chunks don't thrash whole-card layout. */
|
||||
const STREAMING_PREVIEW_LINES = 4;
|
||||
/** Expanded mode shows up to this many lines so the card can't swallow the whole viewport. */
|
||||
const EXPANDED_MAX_LINES = 60;
|
||||
|
||||
const MIN_ELAPSED_MS_FOR_RATE = 500;
|
||||
const MIN_TOKENS_FOR_RATE = 4;
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 10000) return `${(n / 1000).toFixed(1)}k`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(2)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function tokenRate(
|
||||
text: string,
|
||||
startTs: number,
|
||||
endTs: number,
|
||||
): { tokens: number; tps: number | null } {
|
||||
const tokens = countTokens(text);
|
||||
const elapsedMs = endTs - startTs;
|
||||
if (elapsedMs < MIN_ELAPSED_MS_FOR_RATE || tokens < MIN_TOKENS_FOR_RATE) {
|
||||
return { tokens, tps: null };
|
||||
}
|
||||
return { tokens, tps: Math.round((tokens * 1000) / elapsedMs) };
|
||||
}
|
||||
|
||||
const PILL_RATE = { bg: "#11141a", fg: "#8b949e" } as const;
|
||||
|
||||
export function StreamingCard({ card }: { card: StreamingCardData }): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const expanded = useContext(LiveExpandContext);
|
||||
const reserveCap = expanded ? EXPANDED_MAX_LINES + 2 : STREAMING_PREVIEW_LINES + 2;
|
||||
useReserveRows("stream", {
|
||||
min: STREAMING_PREVIEW_LINES + 1,
|
||||
max: STREAMING_PREVIEW_LINES + 2,
|
||||
max: reserveCap,
|
||||
});
|
||||
// Re-render at 1Hz so the rate keeps updating even when chunks stall.
|
||||
// Frozen once `card.done` is true — settled cards render via Static.
|
||||
useSlowTick();
|
||||
|
||||
const modelBadge = card.model ? modelBadgeFor(card.model) : null;
|
||||
const modelPill = modelBadge ? (
|
||||
@@ -28,9 +62,24 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
|
||||
) : null;
|
||||
|
||||
if (card.done && !card.aborted) {
|
||||
const { tokens, tps } = tokenRate(card.text, card.ts, card.endedAt ?? Date.now());
|
||||
const ratePill =
|
||||
tokens >= MIN_TOKENS_FOR_RATE && tps !== null ? (
|
||||
<Pill label={`${formatTokenCount(tokens)} tok · ${tps} t/s`} {...PILL_RATE} bold={false} />
|
||||
) : null;
|
||||
return (
|
||||
<Card tone={TONE.ok}>
|
||||
<CardHeader glyph="‹" tone={TONE.ok} title="reply" right={modelPill} />
|
||||
<CardHeader
|
||||
glyph="‹"
|
||||
tone={TONE.ok}
|
||||
title="reply"
|
||||
right={
|
||||
<>
|
||||
{ratePill}
|
||||
{modelPill}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Markdown text={card.text} />
|
||||
</Card>
|
||||
);
|
||||
@@ -39,12 +88,23 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
|
||||
const lineCells = Math.max(20, cols - 4);
|
||||
const allLines = card.text.length > 0 ? card.text.split("\n") : [""];
|
||||
const visualLines = allLines.flatMap((l) => wrapToCells(l, lineCells));
|
||||
const visible = visualLines.slice(-STREAMING_PREVIEW_LINES);
|
||||
const cap = expanded ? EXPANDED_MAX_LINES : STREAMING_PREVIEW_LINES;
|
||||
const visible = visualLines.slice(-cap);
|
||||
const droppedAbove = Math.max(0, visualLines.length - visible.length);
|
||||
const aborted = !!card.aborted;
|
||||
const headColor = aborted ? TONE.err : TONE_ACTIVE.brand;
|
||||
const glyph = aborted ? "‹" : "◈";
|
||||
const headLabel = aborted ? "aborted" : "writing…";
|
||||
|
||||
const { tokens: liveTokens, tps: liveTps } = tokenRate(card.text, card.ts, Date.now());
|
||||
const liveRatePill =
|
||||
!aborted && liveTokens >= MIN_TOKENS_FOR_RATE && liveTps !== null ? (
|
||||
<Pill label={`${liveTps} t/s`} {...PILL_RATE} bold={false} />
|
||||
) : null;
|
||||
const expandPill = !aborted ? (
|
||||
<Pill label={expanded ? "expanded ⌃o" : "preview ⌃o"} {...PILL_RATE} bold={false} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Card tone={headColor}>
|
||||
<CardHeader
|
||||
@@ -53,13 +113,20 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
|
||||
title={headLabel}
|
||||
right={
|
||||
<>
|
||||
{liveRatePill}
|
||||
{expandPill}
|
||||
{aborted ? null : <Spinner kind="braille" color={TONE_ACTIVE.brand} />}
|
||||
{modelPill}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{expanded && droppedAbove > 0 ? (
|
||||
<Text
|
||||
color={FG.faint}
|
||||
>{`⋯ ${droppedAbove} earlier line${droppedAbove === 1 ? "" : "s"} above`}</Text>
|
||||
) : null}
|
||||
{visible.map((line, i) => (
|
||||
<Box key={`${card.id}:${allLines.length - visible.length + i}`} flexDirection="row">
|
||||
<Box key={`${card.id}:${visualLines.length - visible.length + i}`} flexDirection="row">
|
||||
<Text color={aborted ? FG.meta : FG.body}>{clipToCells(line, lineCells)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type {
|
||||
DashboardEvent,
|
||||
PickerAction,
|
||||
PickerItem,
|
||||
PickerResolution,
|
||||
} from "../../../server/context.js";
|
||||
|
||||
export interface PickerSnapshot {
|
||||
pickerKind: string;
|
||||
title: string;
|
||||
query?: string;
|
||||
items: PickerItem[];
|
||||
actions: PickerAction[];
|
||||
hasMore?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ViewerSnapshot {
|
||||
viewerKind: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
steps?: Array<{ id: string; title: string; status: "done" | "queued" }>;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface ViewerBroadcastPorts {
|
||||
broadcast: (ev: DashboardEvent) => void;
|
||||
resolverRef: MutableRefObject<(() => void) | null>;
|
||||
snapshotRef: MutableRefObject<ViewerSnapshot | null>;
|
||||
}
|
||||
|
||||
/** Read-only sibling of `usePickerBroadcast` — viewer modals carry no selection so only `close` flows back. */
|
||||
export function useViewerBroadcast(
|
||||
active: boolean,
|
||||
snapshot: ViewerSnapshot,
|
||||
onClose: () => void,
|
||||
ports: ViewerBroadcastPorts,
|
||||
): void {
|
||||
const { broadcast, resolverRef, snapshotRef } = ports;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return () => {
|
||||
broadcast({ kind: "modal-down", modalKind: "viewer" });
|
||||
if (resolverRef.current) resolverRef.current = null;
|
||||
if (snapshotRef.current) snapshotRef.current = null;
|
||||
};
|
||||
}, [active, broadcast, resolverRef, snapshotRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
snapshotRef.current = snapshot;
|
||||
resolverRef.current = onClose;
|
||||
broadcast({ kind: "modal-up", modal: { kind: "viewer", ...snapshot } });
|
||||
}, [active, snapshot, onClose, broadcast, resolverRef, snapshotRef]);
|
||||
}
|
||||
|
||||
export interface PickerBroadcastPorts {
|
||||
broadcast: (ev: DashboardEvent) => void;
|
||||
resolverRef: MutableRefObject<((res: PickerResolution) => void) | null>;
|
||||
snapshotRef: MutableRefObject<PickerSnapshot | null>;
|
||||
}
|
||||
|
||||
/** Mirrors a TUI picker into the dashboard via modal-up/down events. Caller passes stable refs from App.tsx so identity does not churn the effect. */
|
||||
export function usePickerBroadcast(
|
||||
active: boolean,
|
||||
snapshot: PickerSnapshot,
|
||||
onResolve: (res: PickerResolution) => void,
|
||||
ports: PickerBroadcastPorts,
|
||||
): void {
|
||||
const { broadcast, resolverRef, snapshotRef } = ports;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
return () => {
|
||||
broadcast({ kind: "modal-down", modalKind: "picker" });
|
||||
if (resolverRef.current) resolverRef.current = null;
|
||||
if (snapshotRef.current) snapshotRef.current = null;
|
||||
};
|
||||
}, [active, broadcast, resolverRef, snapshotRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
snapshotRef.current = snapshot;
|
||||
resolverRef.current = onResolve;
|
||||
broadcast({ kind: "modal-up", modal: { kind: "picker", ...snapshot } });
|
||||
}, [active, snapshot, onResolve, broadcast, resolverRef, snapshotRef]);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** stdin-queue drain on exit — eats stuck terminal-feature-detection responses (#365). */
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
/** Eats stuck OSC/CPR/DA replies on exit so fish/bash don't print them as input (#365). */
|
||||
export async function drainTtyResponses(timeoutMs = 50): Promise<void> {
|
||||
const stdin = process.stdin;
|
||||
if (!stdin.isTTY && typeof (stdin as { setRawMode?: unknown }).setRawMode !== "function") {
|
||||
return;
|
||||
}
|
||||
let raised = false;
|
||||
try {
|
||||
stdin.setRawMode(true);
|
||||
raised = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
stdin.resume();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const onData = (_chunk: Buffer | string): void => {
|
||||
// Discard — anything pending here is a terminal-feature reply.
|
||||
};
|
||||
stdin.on("data", onData);
|
||||
const timer = setTimeout(() => {
|
||||
stdin.off("data", onData);
|
||||
stdin.pause();
|
||||
if (raised) {
|
||||
try {
|
||||
stdin.setRawMode(false);
|
||||
} catch {
|
||||
/* stdin may already be closed; ignore */
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
timer.unref();
|
||||
});
|
||||
}
|
||||
@@ -32,9 +32,7 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
return { kind: "consumed" };
|
||||
}
|
||||
if (result.clear) {
|
||||
// 2J + 3J + H: nuke viewport AND scrollback (visible buffer + the
|
||||
// off-screen lines). Then reset state.cards so React doesn't
|
||||
// immediately repaint everything that was just wiped.
|
||||
// 2J + 3J + H: visible buffer + scrollback + cursor home.
|
||||
ctx.stdoutWrite("\x1b[2J\x1b[3J\x1b[H");
|
||||
ctx.log.reset();
|
||||
if (result.info) ctx.log.pushInfo(result.info);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/** Rows advanced per PgUp / PgDn / arrow / wheel — small for smooth feel. */
|
||||
export const SCROLL_PAGE_ROWS = 3;
|
||||
|
||||
export interface ChatScrollState {
|
||||
/** How many rows of content are above the visible viewport. */
|
||||
scrollRows: number;
|
||||
/** True when the user is following the bottom (auto-advances on new content). */
|
||||
pinned: boolean;
|
||||
scrollUp: () => void;
|
||||
scrollDown: () => void;
|
||||
/** Jump straight to the latest content and resume auto-follow (End key). */
|
||||
jumpToBottom: () => void;
|
||||
/** CardStream calls this once it has measured inner/outer Box heights. */
|
||||
setMaxScroll: (rows: number) => void;
|
||||
}
|
||||
|
||||
/** Row-precision scroll state. CardStream reports maxScroll back via setMaxScroll once Ink has laid out the inner column. */
|
||||
export function useChatScroll(): ChatScrollState {
|
||||
const [scrollRows, setScrollRows] = useState(0);
|
||||
const [pinned, setPinned] = useState(true);
|
||||
const [maxScroll, setMaxScrollState] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (pinned) setScrollRows(maxScroll);
|
||||
}, [pinned, maxScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRows > maxScroll) setScrollRows(maxScroll);
|
||||
}, [scrollRows, maxScroll]);
|
||||
|
||||
const scrollUp = useCallback(() => {
|
||||
setPinned(false);
|
||||
setScrollRows((o) => Math.max(0, o - SCROLL_PAGE_ROWS));
|
||||
}, []);
|
||||
|
||||
const scrollDown = useCallback(() => {
|
||||
setScrollRows((o) => {
|
||||
const next = Math.min(maxScroll, o + SCROLL_PAGE_ROWS);
|
||||
if (next >= maxScroll) setPinned(true);
|
||||
return next;
|
||||
});
|
||||
}, [maxScroll]);
|
||||
|
||||
const jumpToBottom = useCallback(() => setPinned(true), []);
|
||||
|
||||
const setMaxScroll = useCallback((rows: number) => setMaxScrollState(rows), []);
|
||||
|
||||
return { scrollRows, pinned, scrollUp, scrollDown, jumpToBottom, setMaxScroll };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -27,7 +27,13 @@ const CSI_TAIL_TO_FLAGS: ReadonlyArray<{ tail: string; flags: CsiKeyFlags }> = [
|
||||
// Forward-delete (the key labelled Delete on most keyboards).
|
||||
{ tail: "[3~", flags: { delete: true } },
|
||||
// Shift+Tab — terminal sends `\x1b[Z` rather than tab-with-shift.
|
||||
// `[1;2Z` is the modifier-encoded variant some Windows PowerShell
|
||||
// hosts emit; `[27;2;9~` and `[9;2u` cover modifyOtherKeys / Kitty
|
||||
// forms. Issue #373.
|
||||
{ tail: "[Z", flags: { shift: true, tab: true } },
|
||||
{ tail: "[1;2Z", flags: { shift: true, tab: true } },
|
||||
{ tail: "[27;2;9~", flags: { shift: true, tab: true } },
|
||||
{ tail: "[9;2u", flags: { shift: true, tab: true } },
|
||||
];
|
||||
|
||||
function alreadyStructured(flags: CsiKeyFlags): boolean {
|
||||
|
||||
@@ -1,13 +1,62 @@
|
||||
import { Static } from "ink";
|
||||
import { Box, type DOMElement, Text, useBoxMetrics } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { CardRenderer } from "../cards/CardRenderer.js";
|
||||
import { ActiveCardContext } from "../primitives/Card.js";
|
||||
import type { Card } from "../state/cards.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
|
||||
/** Settled = no future event can mutate it; safe to commit via Ink's Static. */
|
||||
function isSettled(card: Card): boolean {
|
||||
/**
|
||||
* Row-precision virtual scroll: outer Box clips with overflow="hidden",
|
||||
* inner Box holds all cards and slides up via negative marginTop. Yoga
|
||||
* computes inner height from card layout; useBoxMetrics reports it back
|
||||
* so App can clamp scroll bounds. New cards arriving while the user is
|
||||
* scrolled up sit below the visible window — bytes outside outer bounds
|
||||
* aren't written, so terminal text selection survives streaming activity.
|
||||
*/
|
||||
export function CardStream({
|
||||
scrollRows,
|
||||
onMaxScrollChange,
|
||||
suppressLive = false,
|
||||
}: {
|
||||
scrollRows: number;
|
||||
onMaxScrollChange: (rows: number) => void;
|
||||
suppressLive?: boolean;
|
||||
}): React.ReactElement {
|
||||
const cards = useAgentState((s) => s.cards);
|
||||
// useBoxMetrics types its ref as RefObject<DOMElement> (non-null); the
|
||||
// null! assertion satisfies the signature — Ink populates the ref on
|
||||
// mount before the metrics hook reads it.
|
||||
const outerRef = useRef<DOMElement>(null!);
|
||||
const innerRef = useRef<DOMElement>(null!);
|
||||
const outer = useBoxMetrics(outerRef);
|
||||
const inner = useBoxMetrics(innerRef);
|
||||
const maxScroll = Math.max(0, inner.height - outer.height);
|
||||
|
||||
useEffect(() => {
|
||||
onMaxScrollChange(maxScroll);
|
||||
}, [maxScroll, onMaxScrollChange]);
|
||||
|
||||
let visible = cards;
|
||||
if (suppressLive && cards.length > 0 && !isFullySettled(cards[cards.length - 1]!)) {
|
||||
visible = cards.slice(0, -1);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{scrollRows > 0 ? <Text color={FG.faint}>{" ↑ earlier — PgUp / wheel / ↑"}</Text> : null}
|
||||
<Box ref={outerRef} flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<Box ref={innerRef} flexDirection="column" marginTop={-scrollRows} flexShrink={0}>
|
||||
{visible.map((card) => (
|
||||
<CardRenderer key={card.id} card={card} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isFullySettled(card: Card): boolean {
|
||||
switch (card.kind) {
|
||||
case "streaming":
|
||||
case "tool":
|
||||
@@ -15,49 +64,12 @@ function isSettled(card: Card): boolean {
|
||||
return card.done || !!card.aborted;
|
||||
case "reasoning":
|
||||
return !card.streaming || !!card.aborted;
|
||||
case "task":
|
||||
case "subagent":
|
||||
return card.status !== "running";
|
||||
case "plan":
|
||||
return card.steps.every((s) => s.status === "done" || s.status === "skipped");
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function splitCardStream(
|
||||
cards: readonly Card[],
|
||||
suppressLive = false,
|
||||
): { committed: Card[]; live: Card[] } {
|
||||
const lastIdx = cards.length - 1;
|
||||
const lastCard = lastIdx >= 0 ? (cards[lastIdx] as Card) : null;
|
||||
const lastIsLive = !!lastCard && !isSettled(lastCard);
|
||||
if (suppressLive && lastIsLive) {
|
||||
return { committed: cards.slice(0, lastIdx), live: [] };
|
||||
}
|
||||
const committed: Card[] = lastIsLive ? cards.slice(0, lastIdx) : cards.slice();
|
||||
const live: Card[] = lastIsLive && lastCard ? [lastCard] : [];
|
||||
return { committed, live };
|
||||
}
|
||||
|
||||
export function CardStream({
|
||||
suppressLive = false,
|
||||
}: {
|
||||
suppressLive?: boolean;
|
||||
}): React.ReactElement {
|
||||
const cards = useAgentState((s) => s.cards);
|
||||
const { committed, live } = splitCardStream(cards, suppressLive);
|
||||
// Static items are emitted via bridge.emitStatic, which renders them in an
|
||||
// off-tree React reconciler — context from the live tree does NOT propagate.
|
||||
// The ActiveCardContext.Provider must therefore live inside the children
|
||||
// function so it travels with the rendered subtree.
|
||||
return (
|
||||
<>
|
||||
<Static items={committed}>
|
||||
{(card) => (
|
||||
<ActiveCardContext.Provider value={false} key={card.id}>
|
||||
<CardRenderer card={card} />
|
||||
</ActiveCardContext.Provider>
|
||||
)}
|
||||
</Static>
|
||||
{live.map((card) => (
|
||||
<CardRenderer key={card.id} card={card} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
/** ctrl-o toggles this; live streaming card swaps 4-line tail for full-tail view. */
|
||||
export const LiveExpandContext = createContext<boolean>(false);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform)
|
||||
import React from "react";
|
||||
import type { ApplyResult } from "../../../code/edit-blocks.js";
|
||||
@@ -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({
|
||||
@@ -113,17 +103,20 @@ function ModePill({
|
||||
export function UndoBanner({
|
||||
banner,
|
||||
}: {
|
||||
banner: { results: ApplyResult[]; expiresAt: number };
|
||||
banner: { results: ApplyResult[]; expiresAt: number; pausedRemainingMs: number | null };
|
||||
}) {
|
||||
useTick();
|
||||
const totalMs = 5000;
|
||||
const remainingMs = Math.max(0, banner.expiresAt - Date.now());
|
||||
const paused = banner.pausedRemainingMs !== null;
|
||||
const remainingMs = paused
|
||||
? (banner.pausedRemainingMs ?? 0)
|
||||
: Math.max(0, banner.expiresAt - Date.now());
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
const ok = banner.results.filter((r) => r.status === "applied" || r.status === "created").length;
|
||||
const total = banner.results.length;
|
||||
const urgent = remainingSec <= 1;
|
||||
const urgent = !paused && remainingSec <= 1;
|
||||
const pct = (remainingMs / totalMs) * 100;
|
||||
const tone = urgent ? TONE.err : TONE.accent;
|
||||
const tone = paused ? TONE.warn : urgent ? TONE.err : TONE.accent;
|
||||
return (
|
||||
<Box marginY={1} paddingX={1}>
|
||||
<Text backgroundColor={TONE.accent} color="black" bold>
|
||||
@@ -133,11 +126,15 @@ export function UndoBanner({
|
||||
<Text backgroundColor={TONE.brand} color="black" bold>
|
||||
{" u "}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{" to undo "}</Text>
|
||||
<Text color={FG.faint}>{paused ? " to undo · " : " to undo · "}</Text>
|
||||
<Text backgroundColor={paused ? TONE.warn : FG.faint} color="black" bold>
|
||||
{" space "}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{paused ? " to resume " : " to pause "}</Text>
|
||||
<CharBar pct={pct} width={20} color={tone} showLabel={false} />
|
||||
<Text color={FG.faint}>{" "}</Text>
|
||||
<Text color={tone} bold={urgent}>
|
||||
{`${remainingSec}s`}
|
||||
<Text color={tone} bold={urgent || paused}>
|
||||
{paused ? `${remainingSec}s · paused` : `${remainingSec}s`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,7 +24,6 @@ export function MarkdownLines({
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{lines.map((line, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + stable per render
|
||||
<LineRow key={`md-${i}-${line.kind}`} line={line} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -127,7 +127,6 @@ function ListItem({
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: list-item children are positional and stable per render
|
||||
return <List key={`l-${i}`} token={tok as Tokens.List} depth={depth + 1} />;
|
||||
}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: list-item children are positional and stable per render
|
||||
return <BlockToken key={`b-${i}-${tok.type}`} token={tok} />;
|
||||
})}
|
||||
</Box>
|
||||
|
||||
@@ -28,8 +28,10 @@ export interface MultilineAction {
|
||||
/** When `true`, fire `onSubmit(submitValue ?? value)`. */
|
||||
submit: boolean;
|
||||
submitValue?: string;
|
||||
/** Set when ↑/↓ hits a buffer boundary — parent handles history recall. */
|
||||
/** Set on Ctrl+P / Ctrl+N — parent handles prompt-history recall. */
|
||||
historyHandoff?: "prev" | "next";
|
||||
/** Set when ↑/↓ fires on an empty buffer — parent scrolls the chat history. */
|
||||
chatScrollHandoff?: "up" | "down";
|
||||
/** Reducer is pure — hands raw paste to PromptInput which allocates a sentinel and inserts that. */
|
||||
pasteRequest?: { content: string };
|
||||
}
|
||||
@@ -68,12 +70,11 @@ export function processMultilineKey(
|
||||
return cursor === value.length ? NOOP : { next: null, cursor: value.length, submit: false };
|
||||
}
|
||||
|
||||
// History recall is bound to Ctrl+P / Ctrl+N (bash readline
|
||||
// convention) rather than ↑/↓. Reason: Windows Terminal + ConPTY
|
||||
// translates mouse wheel events to ↑/↓ keystrokes for raw-mode
|
||||
// child processes — indistinguishable at the byte level. If ↑/↓
|
||||
// recalled history, every wheel-scroll would corrupt the prompt
|
||||
// with a prior message. Ctrl+P/N is wheel-immune.
|
||||
// Prompt-history recall is on Ctrl+P / Ctrl+N (bash readline
|
||||
// convention). ↑/↓ on an empty buffer scrolls chat history because
|
||||
// Windows Terminal + ConPTY translates mouse wheel events to ↑/↓ in
|
||||
// raw mode — the only way to make wheel-up scroll chat is to map ↑
|
||||
// to chat-scroll. Ctrl+P/N is wheel-immune.
|
||||
if (key.ctrl && key.input === "p") {
|
||||
return { ...NOOP, historyHandoff: "prev" };
|
||||
}
|
||||
@@ -90,12 +91,12 @@ export function processMultilineKey(
|
||||
return { next: null, cursor: Math.min(value.length, cursor + 1), submit: false };
|
||||
}
|
||||
if (key.upArrow) {
|
||||
if (value.length === 0) return { ...NOOP, historyHandoff: "prev" };
|
||||
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "up" };
|
||||
const moved = moveCursorUp(value, cursor);
|
||||
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
|
||||
}
|
||||
if (key.downArrow) {
|
||||
if (value.length === 0) return { ...NOOP, historyHandoff: "next" };
|
||||
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "down" };
|
||||
const moved = moveCursorDown(value, cursor);
|
||||
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Box } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { useContext } from "react";
|
||||
|
||||
/** Settled cards (in scrollback) drop the border + paddingX so history reads as flat lines. */
|
||||
/** Settled cards (in scrollback) drop border + padding + margin so history collapses to flat lines. */
|
||||
export const ActiveCardContext = React.createContext(true);
|
||||
|
||||
export interface CardProps {
|
||||
@@ -24,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,7 +1,7 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import React, { useContext } from "react";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
import { ActiveCardContext } from "./Card.js";
|
||||
|
||||
export type MetaItem = string | { text: string; color: string };
|
||||
|
||||
@@ -31,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>
|
||||
@@ -44,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;
|
||||
@@ -56,7 +59,7 @@ export function CardHeader({
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{right}
|
||||
{active ? right : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import { Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { useTick } from "../ticker.js";
|
||||
|
||||
const FRAMES = {
|
||||
circle: ["◐", "◓", "◑", "◒"] as const,
|
||||
braille: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"] as const,
|
||||
};
|
||||
|
||||
const CADENCE_MS = {
|
||||
circle: 200,
|
||||
braille: 80,
|
||||
} as const;
|
||||
|
||||
export interface SpinnerProps {
|
||||
kind?: keyof typeof FRAMES;
|
||||
color?: string;
|
||||
@@ -19,12 +16,8 @@ export interface SpinnerProps {
|
||||
|
||||
export function Spinner({ kind = "circle", color, bold }: SpinnerProps): React.ReactElement {
|
||||
const frames = FRAMES[kind];
|
||||
const [frame, setFrame] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = setInterval(() => setFrame((f) => (f + 1) % frames.length), CADENCE_MS[kind]);
|
||||
return () => clearInterval(id);
|
||||
}, [kind, frames.length]);
|
||||
const tick = useTick();
|
||||
const frame = tick % frames.length;
|
||||
|
||||
return (
|
||||
<Text bold={bold} color={color}>
|
||||
|
||||
@@ -77,10 +77,17 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
argsHint: "[list|show <name>|forget <name>|clear <scope> confirm]",
|
||||
summary: "show / manage pinned memory (REASONIX.md + ~/.reasonix/memory)",
|
||||
},
|
||||
{
|
||||
cmd: "search-engine",
|
||||
argsHint: "<mojeek|searxng> [<endpoint>]",
|
||||
summary: "switch web search backend — mojeek (default, no deps) or searxng (self-hosted)",
|
||||
argCompleter: ["mojeek", "searxng"],
|
||||
aliases: ["se"],
|
||||
},
|
||||
{
|
||||
cmd: "skill",
|
||||
argsHint: "[list|show <name>|<name> [args]]",
|
||||
summary: "list / run user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
|
||||
argsHint: "[list|show <name>|new <name>|<name> [args]]",
|
||||
summary: "list / run / scaffold user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
|
||||
},
|
||||
{
|
||||
cmd: "hooks",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { handlers as plansHandlers } from "./handlers/plans.js";
|
||||
import { handlers as semanticHandlers } from "./handlers/semantic.js";
|
||||
import { handlers as sessionsHandlers } from "./handlers/sessions.js";
|
||||
import { handlers as skillHandlers } from "./handlers/skill.js";
|
||||
import { handlers as webSearchEngineHandlers } from "./handlers/web-search-engine.js";
|
||||
import type { SlashContext, SlashResult } from "./types.js";
|
||||
|
||||
/** Synchronous return — async work fires-and-forgets via `ctx.postInfo` to keep input non-blocking. */
|
||||
@@ -38,6 +39,7 @@ const HANDLERS: Record<string, SlashHandler> = {
|
||||
...semanticHandlers,
|
||||
...sessionsHandlers,
|
||||
...skillHandlers,
|
||||
...webSearchEngineHandlers,
|
||||
};
|
||||
|
||||
export function handleSlash(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,10 +7,10 @@ const model: SlashHandler = (args, loop, ctx) => {
|
||||
const id = args[0];
|
||||
const known = ctx.models ?? null;
|
||||
if (!id) {
|
||||
const hint = known && known.length > 0 ? known.join(" | ") : t("handlers.model.modelHint");
|
||||
return { info: t("handlers.model.modelUsage", { hint }) };
|
||||
return { openModelPicker: true };
|
||||
}
|
||||
loop.configure({ model: id });
|
||||
ctx.dispatch?.({ type: "session.model.change", model: id });
|
||||
if (known && known.length > 0 && !known.includes(id)) {
|
||||
return {
|
||||
info: t("handlers.model.modelNotInCatalog", { id, list: known.join(", ") }),
|
||||
@@ -53,7 +53,7 @@ const harvest: SlashHandler = (args, loop) => {
|
||||
return { info: t("handlers.model.harvestOff") };
|
||||
};
|
||||
|
||||
const preset: SlashHandler = (args, loop) => {
|
||||
const preset: SlashHandler = (args, loop, ctx) => {
|
||||
const name = (args[0] ?? "").toLowerCase();
|
||||
const applyAndPersist = (effort: "high" | "max") => {
|
||||
try {
|
||||
@@ -62,8 +62,7 @@ const preset: SlashHandler = (args, loop) => {
|
||||
/* disk full / perms — runtime change still took effect */
|
||||
}
|
||||
};
|
||||
if (name === "auto") {
|
||||
const p = PRESETS.auto;
|
||||
const apply = (p: (typeof PRESETS)[keyof typeof PRESETS]) => {
|
||||
loop.configure({
|
||||
model: p.model,
|
||||
autoEscalate: p.autoEscalate,
|
||||
@@ -71,31 +70,19 @@ const preset: SlashHandler = (args, loop) => {
|
||||
harvest: p.harvest,
|
||||
branch: p.branch,
|
||||
});
|
||||
ctx.dispatch?.({ type: "session.model.change", model: p.model });
|
||||
applyAndPersist(p.reasoningEffort);
|
||||
};
|
||||
if (name === "auto") {
|
||||
apply(PRESETS.auto);
|
||||
return { info: t("handlers.model.presetAuto") };
|
||||
}
|
||||
if (name === "flash") {
|
||||
const p = PRESETS.flash;
|
||||
loop.configure({
|
||||
model: p.model,
|
||||
autoEscalate: p.autoEscalate,
|
||||
reasoningEffort: p.reasoningEffort,
|
||||
harvest: p.harvest,
|
||||
branch: p.branch,
|
||||
});
|
||||
applyAndPersist(p.reasoningEffort);
|
||||
apply(PRESETS.flash);
|
||||
return { info: t("handlers.model.presetFlash") };
|
||||
}
|
||||
if (name === "pro") {
|
||||
const p = PRESETS.pro;
|
||||
loop.configure({
|
||||
model: p.model,
|
||||
autoEscalate: p.autoEscalate,
|
||||
reasoningEffort: p.reasoningEffort,
|
||||
harvest: p.harvest,
|
||||
branch: p.branch,
|
||||
});
|
||||
applyAndPersist(p.reasoningEffort);
|
||||
apply(PRESETS.pro);
|
||||
return { info: t("handlers.model.presetPro") };
|
||||
}
|
||||
return { info: t("handlers.model.presetUsage") };
|
||||
|
||||
@@ -132,6 +132,9 @@ const status: SlashHandler = (_args, loop, ctx) => {
|
||||
: "";
|
||||
const dashUrl = ctx.getDashboardUrl?.();
|
||||
const dashLine = dashUrl ? t("handlers.observability.statusDash", { url: dashUrl }) : "";
|
||||
const workspaceLine = ctx.codeRoot
|
||||
? t("handlers.observability.statusWorkspace", { path: ctx.codeRoot })
|
||||
: "";
|
||||
const lines = [
|
||||
t("handlers.observability.statusModel", { model: loop.model }),
|
||||
t("handlers.observability.statusFlags", {
|
||||
@@ -145,6 +148,7 @@ const status: SlashHandler = (_args, loop, ctx) => {
|
||||
mcpLine,
|
||||
sessionLine,
|
||||
];
|
||||
if (workspaceLine) lines.push(workspaceLine);
|
||||
if (budgetLine) lines.push(budgetLine);
|
||||
if (pendingLine) lines.push(pendingLine);
|
||||
if (planLine) lines.push(planLine);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -6,6 +6,17 @@ const skill: SlashHandler = (args, _loop, ctx) => {
|
||||
const store = new SkillStore({ projectRoot: ctx.codeRoot });
|
||||
const sub = (args[0] ?? "").toLowerCase();
|
||||
|
||||
if (sub === "new" || sub === "init") {
|
||||
const name = args[1];
|
||||
if (!name) return { info: t("handlers.skill.newUsage") };
|
||||
const wantsGlobal = args.slice(2).includes("--global") || !ctx.codeRoot;
|
||||
const result = store.create(name, wantsGlobal ? "global" : "project");
|
||||
if ("error" in result) {
|
||||
return { info: t("handlers.skill.newError", { reason: result.error }) };
|
||||
}
|
||||
return { info: t("handlers.skill.newCreated", { name, path: result.path }) };
|
||||
}
|
||||
|
||||
if (sub === "" || sub === "list" || sub === "ls") {
|
||||
const skills = store.list();
|
||||
if (skills.length === 0) {
|
||||
@@ -17,7 +28,13 @@ const skill: SlashHandler = (args, _loop, ctx) => {
|
||||
if (!store.hasProjectScope()) {
|
||||
lines.push(t("handlers.skill.listProjectOnly"));
|
||||
}
|
||||
lines.push("", t("handlers.skill.listFrontmatter"), t("handlers.skill.listInvoke"));
|
||||
lines.push(
|
||||
"",
|
||||
t("handlers.skill.listFrontmatter"),
|
||||
t("handlers.skill.listInvoke"),
|
||||
"",
|
||||
t("handlers.skill.listEmptyNewHint"),
|
||||
);
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
const lines = [t("handlers.skill.listHeader", { count: skills.length })];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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> = {
|
||||
"search-engine": (args, _loop, ctx) => {
|
||||
const engine = args[0];
|
||||
if (!engine || (engine !== "mojeek" && engine !== "searxng")) {
|
||||
return {
|
||||
info: [
|
||||
t("handlers.webSearchEngine.currentEngine", { engine: webSearchEngine() }),
|
||||
t("handlers.webSearchEngine.endpoint", { url: webSearchEndpoint() }),
|
||||
"",
|
||||
t("handlers.webSearchEngine.usageHeader"),
|
||||
t("handlers.webSearchEngine.usageMojeek"),
|
||||
t("handlers.webSearchEngine.usageSearxng"),
|
||||
t("handlers.webSearchEngine.usageSearxngUrl"),
|
||||
"",
|
||||
t("handlers.webSearchEngine.alias"),
|
||||
"",
|
||||
t("handlers.webSearchEngine.searxngInfo"),
|
||||
t("handlers.webSearchEngine.searxngInstall"),
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
const cfg = readConfig();
|
||||
cfg.webSearchEngine = engine;
|
||||
if (engine === "searxng" && args[1]) {
|
||||
const raw = args[1];
|
||||
cfg.webSearchEndpoint = raw.includes("://") ? raw : `http://${raw}`;
|
||||
}
|
||||
writeConfig(cfg);
|
||||
|
||||
const note =
|
||||
engine === "searxng"
|
||||
? t("handlers.webSearchEngine.switchedSearxngNote", { endpoint: webSearchEndpoint() })
|
||||
: "";
|
||||
ctx.postInfo?.(t("handlers.webSearchEngine.switched", { engine, note }));
|
||||
|
||||
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,10 @@ 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". */
|
||||
openMcpHub?: { tab: "live" | "marketplace" };
|
||||
/** Open the arg-completer picker for this command (e.g. `/language` → language picker). */
|
||||
|
||||
@@ -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({
|
||||
@@ -122,6 +124,11 @@ const sessionUpdate = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionModelChange = z.object({
|
||||
type: z.literal("session.model.change"),
|
||||
model: z.string().min(1),
|
||||
});
|
||||
|
||||
const focusMove = z.object({
|
||||
type: z.literal("focus.move"),
|
||||
direction: z.enum(["next", "prev", "first", "last"]),
|
||||
@@ -318,6 +325,7 @@ export const AgentEventSchema = z.discriminatedUnion("type", [
|
||||
networkChange,
|
||||
languageChange,
|
||||
sessionUpdate,
|
||||
sessionModelChange,
|
||||
focusMove,
|
||||
focusSet,
|
||||
cardToggle,
|
||||
|
||||
@@ -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 }));
|
||||
@@ -116,6 +116,11 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
|
||||
case "session.update":
|
||||
return { ...state, status: { ...state.status, ...event.patch } };
|
||||
|
||||
case "session.model.change":
|
||||
return state.session.model === event.model
|
||||
? state
|
||||
: { ...state, session: { ...state.session, model: event.model } };
|
||||
|
||||
case "focus.move":
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -62,17 +62,23 @@ const CSI_TAIL_MAP: ReadonlyArray<{ tail: string; ev: KeyEvent }> = [
|
||||
{ tail: "6~", ev: { input: "", pageDown: true } },
|
||||
{ tail: "3~", ev: { input: "", delete: true } },
|
||||
{ tail: "Z", ev: { input: "", shift: true, tab: true } },
|
||||
// modifyOtherKeys (xterm CSI > 4 ; 2 m) sequences for Enter with
|
||||
// modifiers. Only fired when App.tsx has enabled the mode at
|
||||
// Some Windows hosts (PowerShell 7.x conhost path) emit the
|
||||
// modifier-encoded back-tab `\x1b[1;2Z` instead of bare `\x1b[Z`.
|
||||
// Issue #373 — without this entry Shift+Tab is silently dropped.
|
||||
{ tail: "1;2Z", ev: { input: "", shift: true, tab: true } },
|
||||
// modifyOtherKeys (xterm CSI > 4 ; 2 m) sequences for Enter / Tab
|
||||
// with modifiers. Only fired when App.tsx has enabled the mode at
|
||||
// startup; otherwise Shift+Enter stays indistinguishable from Enter.
|
||||
// Modifier encoding: 2=shift, 3=alt, 4=alt+shift, 5=ctrl,
|
||||
// 6=ctrl+shift, 7=ctrl+alt, 8=ctrl+alt+shift. Keycode 13 = Enter.
|
||||
// 6=ctrl+shift, 7=ctrl+alt, 8=ctrl+alt+shift. Keycodes: 9=Tab, 13=Enter.
|
||||
{ tail: "27;2;9~", ev: { input: "", tab: true, shift: true } },
|
||||
{ tail: "27;2;13~", ev: { input: "", return: true, shift: true } },
|
||||
{ tail: "27;5;13~", ev: { input: "", return: true, ctrl: true } },
|
||||
{ tail: "27;6;13~", ev: { input: "", return: true, ctrl: true, shift: true } },
|
||||
// Kitty keyboard protocol — same idea, different envelope:
|
||||
// `\x1b[<keycode>;<mod>u`. Some terminals (kitty, recent Windows
|
||||
// Terminal previews) prefer this shape. Harmless to map here too.
|
||||
{ tail: "9;2u", ev: { input: "", tab: true, shift: true } },
|
||||
{ tail: "13;2u", ev: { input: "", return: true, shift: true } },
|
||||
{ tail: "13;5u", ev: { input: "", return: true, ctrl: true } },
|
||||
{ tail: "13;6u", ev: { input: "", return: true, ctrl: true, shift: true } },
|
||||
|
||||
+32
-52
@@ -1,89 +1,69 @@
|
||||
import React, { type ReactNode, createContext, useContext, useEffect, useState } from "react";
|
||||
import { useAnimation } from "ink";
|
||||
import React, { type ReactNode, createContext, useContext, useState } from "react";
|
||||
|
||||
/**
|
||||
* Two-tier global heartbeat. Both timers are owned by one provider so
|
||||
* we still pay for a single setInterval per cadence, but components
|
||||
* subscribe to whichever tier matches what they actually need to
|
||||
* repaint:
|
||||
* Two-tier global heartbeat backed by Ink 7's `useAnimation`. The
|
||||
* provider only stores an `isActive` boolean; the actual frame timer
|
||||
* lives inside Ink and consolidates with every other useAnimation
|
||||
* caller into a single shared interval.
|
||||
*
|
||||
* - FAST_TICK_MS (120ms) — spinners, glyph pulses, anything that
|
||||
* visibly animates frame-by-frame.
|
||||
* - SLOW_TICK_MS (1000ms) — elapsed-seconds counters, expiry
|
||||
* countdowns, out-of-React mutation pollers (job registries, file
|
||||
* stat refresh). These don't NEED 8Hz re-renders — they update
|
||||
* once per visible-state change anyway, so subscribing them to
|
||||
* the fast tick was 7 wasted re-renders per second per consumer.
|
||||
* countdowns, polling pollers. Don't need 8Hz re-renders.
|
||||
*
|
||||
* Splitting the contexts means a slow consumer (Elapsed timer) no
|
||||
* longer reconciles when only the fast tick increments — React's
|
||||
* context-propagation only fires for subscribers of the changed
|
||||
* context. The streaming row's spinner and elapsed counter used to
|
||||
* re-render together every 120ms; now the elapsed re-renders at 1Hz
|
||||
* while the spinner keeps its 8Hz cadence.
|
||||
* Setting `disabled` flips `isActive` to `false`, which Ink propagates
|
||||
* to every active animation. Repaints stop entirely until isActive
|
||||
* flips back, at which point Ink resets the frame counter to 0 (so
|
||||
* spinners restart from frame 0 — visually identical to a fresh mount).
|
||||
*/
|
||||
export const FAST_TICK_MS = 120;
|
||||
export const SLOW_TICK_MS = 1000;
|
||||
/** @deprecated kept for callers that import the old name. */
|
||||
export const TICK_MS = FAST_TICK_MS;
|
||||
|
||||
const FastTickContext = createContext(0);
|
||||
const SlowTickContext = createContext(0);
|
||||
const TickerActiveContext = createContext(true);
|
||||
|
||||
export interface TickerProviderProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* When true, the provider skips both setIntervals — ticks stay at 0,
|
||||
* all consumers render once and never re-render from the timer. Used
|
||||
* by PLAIN_UI mode so the cursor and any surviving spinners don't drive
|
||||
* repaints on fragile Windows terminals.
|
||||
* When true, every tick-driven animation pauses. Used by PLAIN_UI
|
||||
* mode, modal overlays, and the idle-gate so a quiescent TUI is
|
||||
* byte-stable (cursor blink and gradient pulses don't re-render).
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function TickerProvider({ children, disabled }: TickerProviderProps) {
|
||||
const [fast, setFast] = useState(0);
|
||||
const [slow, setSlow] = useState(0);
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
const fastId = setInterval(() => setFast((t) => t + 1), FAST_TICK_MS);
|
||||
const slowId = setInterval(() => setSlow((t) => t + 1), SLOW_TICK_MS);
|
||||
return () => {
|
||||
clearInterval(fastId);
|
||||
clearInterval(slowId);
|
||||
};
|
||||
}, [disabled]);
|
||||
return (
|
||||
<FastTickContext.Provider value={fast}>
|
||||
<SlowTickContext.Provider value={slow}>{children}</SlowTickContext.Provider>
|
||||
</FastTickContext.Provider>
|
||||
);
|
||||
return <TickerActiveContext.Provider value={!disabled}>{children}</TickerActiveContext.Provider>;
|
||||
}
|
||||
|
||||
function useTickerActive(): boolean {
|
||||
return useContext(TickerActiveContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast tick — re-renders the calling component every {@link FAST_TICK_MS}.
|
||||
* Use for spinner frames, glyph pulses, and anything else that
|
||||
* visibly animates. If your component just shows a counter that
|
||||
* updates once per second, prefer {@link useSlowTick}.
|
||||
* Fast tick — re-renders the calling component every FAST_TICK_MS
|
||||
* (120ms). Use for spinner frames, glyph pulses, anything that
|
||||
* visibly animates frame-by-frame.
|
||||
*/
|
||||
export function useTick(): number {
|
||||
return useContext(FastTickContext);
|
||||
const isActive = useTickerActive();
|
||||
return useAnimation({ interval: FAST_TICK_MS, isActive }).frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slow tick — re-renders the calling component every {@link SLOW_TICK_MS}.
|
||||
* Use for elapsed-seconds counters, expiry countdowns, or pollers
|
||||
* that just need a "what's the time NOW?" trigger once per second.
|
||||
* Slow tick — re-renders the calling component every SLOW_TICK_MS
|
||||
* (1000ms). Use for elapsed-seconds counters, expiry countdowns,
|
||||
* or pollers that just need a "what's the time NOW?" trigger once
|
||||
* per second.
|
||||
*/
|
||||
export function useSlowTick(): number {
|
||||
return useContext(SlowTickContext);
|
||||
const isActive = useTickerActive();
|
||||
return useAnimation({ interval: SLOW_TICK_MS, isActive }).frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds elapsed since the calling component mounted. Subscribes to
|
||||
* the slow tick — re-renders at 1Hz, which is exactly what a
|
||||
* second-resolution counter needs. Earlier versions used the fast
|
||||
* tick and re-rendered 8x/sec just to display the same number.
|
||||
*/
|
||||
/** Seconds elapsed since mount. Re-renders at 1Hz via the slow tick. */
|
||||
export function useElapsedSeconds(): number {
|
||||
const [start] = useState(() => Date.now());
|
||||
useSlowTick();
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
export interface UndoBannerState {
|
||||
results: ApplyResult[];
|
||||
expiresAt: number;
|
||||
/** Set when the user paused the countdown; banner stays up until they resume or hit `u`. */
|
||||
pausedRemainingMs: number | null;
|
||||
}
|
||||
|
||||
export interface UseEditHistoryResult {
|
||||
@@ -30,6 +32,8 @@ export interface UseEditHistoryResult {
|
||||
) => void;
|
||||
/** Replaces the dismiss timer so multiple edits in one turn don't prematurely expire the window. */
|
||||
armUndoBanner: (results: ApplyResult[]) => void;
|
||||
/** Pause / resume the active undo countdown. No-ops if the banner is already settled. */
|
||||
toggleUndoPause: () => void;
|
||||
codeUndo: (args?: readonly string[]) => string;
|
||||
codeHistory: () => string;
|
||||
codeShowEdit: (args?: readonly string[]) => string;
|
||||
@@ -77,7 +81,7 @@ export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEd
|
||||
);
|
||||
|
||||
const armUndoBanner = useCallback<UseEditHistoryResult["armUndoBanner"]>((results) => {
|
||||
setUndoBanner({ results, expiresAt: Date.now() + 5000 });
|
||||
setUndoBanner({ results, expiresAt: Date.now() + 5000, pausedRemainingMs: null });
|
||||
if (undoTimeoutRef.current) clearTimeout(undoTimeoutRef.current);
|
||||
undoTimeoutRef.current = setTimeout(() => {
|
||||
setUndoBanner(null);
|
||||
@@ -85,6 +89,26 @@ export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEd
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const toggleUndoPause = useCallback<UseEditHistoryResult["toggleUndoPause"]>(() => {
|
||||
setUndoBanner((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (prev.pausedRemainingMs === null) {
|
||||
const remaining = Math.max(0, prev.expiresAt - Date.now());
|
||||
if (undoTimeoutRef.current) {
|
||||
clearTimeout(undoTimeoutRef.current);
|
||||
undoTimeoutRef.current = null;
|
||||
}
|
||||
return { ...prev, pausedRemainingMs: remaining };
|
||||
}
|
||||
const remaining = prev.pausedRemainingMs;
|
||||
undoTimeoutRef.current = setTimeout(() => {
|
||||
setUndoBanner(null);
|
||||
undoTimeoutRef.current = null;
|
||||
}, remaining);
|
||||
return { ...prev, expiresAt: Date.now() + remaining, pausedRemainingMs: null };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const codeUndo = useCallback<UseEditHistoryResult["codeUndo"]>(
|
||||
(args = []) => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
@@ -266,6 +290,7 @@ export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEd
|
||||
undoBanner,
|
||||
recordEdit,
|
||||
armUndoBanner,
|
||||
toggleUndoPause,
|
||||
codeUndo,
|
||||
codeHistory,
|
||||
codeShowEdit,
|
||||
|
||||
@@ -158,6 +158,7 @@ You have TWO tools for running shell commands, and picking the right one is non-
|
||||
|
||||
After \`run_background\`, tools available to you:
|
||||
- \`job_output(jobId, tailLines?)\` — read recent logs to verify startup / debug errors.
|
||||
- \`wait_for_job(jobId, timeoutMs?)\` — block until the job exits or emits new output. Prefer this over repeating identical \`job_output\` calls while you're intentionally waiting.
|
||||
- \`list_jobs\` — see every job this session (running + exited).
|
||||
- \`stop_job(jobId)\` — SIGTERM → SIGKILL after grace. Stop before switching port / config.
|
||||
|
||||
|
||||
+46
-10
@@ -33,6 +33,10 @@ export interface ReasonixConfig {
|
||||
session?: string | null;
|
||||
setupCompleted?: boolean;
|
||||
search?: boolean;
|
||||
/** Web search engine backend: "mojeek" (default, scrapes Mojeek) or "searxng" (self-hosted SearXNG). */
|
||||
webSearchEngine?: "mojeek" | "searxng";
|
||||
/** Base URL for SearXNG instance (default http://localhost:8080). */
|
||||
webSearchEndpoint?: string;
|
||||
projects?: {
|
||||
[absoluteRootDir: string]: {
|
||||
shellAllowed?: string[];
|
||||
@@ -93,18 +97,45 @@ export function searchEnabled(path: string = defaultConfigPath()): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function webSearchEngine(path: string = defaultConfigPath()): "mojeek" | "searxng" {
|
||||
const cfg = readConfig(path).webSearchEngine;
|
||||
if (cfg === "searxng") return "searxng";
|
||||
return "mojeek";
|
||||
}
|
||||
|
||||
export function webSearchEndpoint(path: string = defaultConfigPath()): string {
|
||||
const cfg = readConfig(path).webSearchEndpoint;
|
||||
if (cfg && typeof cfg === "string") return cfg;
|
||||
return "http://localhost:8080";
|
||||
}
|
||||
|
||||
export function saveApiKey(key: string, path: string = defaultConfigPath()): void {
|
||||
const cfg = readConfig(path);
|
||||
cfg.apiKey = key.trim();
|
||||
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(
|
||||
@@ -116,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);
|
||||
}
|
||||
|
||||
@@ -132,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;
|
||||
}
|
||||
@@ -147,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(
|
||||
|
||||
+191
-9
@@ -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: {
|
||||
@@ -710,6 +854,8 @@ export const EN: TranslationSchema = {
|
||||
statusBudget: " budget ${spent} / ${cap} ({pct}%){tag}",
|
||||
statusSession: ' session "{name}" · {count} messages in log (resumed {resumed})',
|
||||
statusSessionEphemeral: " session (ephemeral — no persistence)",
|
||||
statusWorkspace:
|
||||
" workspace {path} · pinned at launch (relaunch with --dir <path> to switch)",
|
||||
statusMcp: " mcp {servers} server(s), {tools} tool(s) in registry",
|
||||
statusEdits: " edits {count} pending (/apply to commit, /discard to drop)",
|
||||
statusPlan: " plan ON — writes gated (submit_plan + approval)",
|
||||
@@ -734,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`.",
|
||||
@@ -805,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:
|
||||
@@ -815,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:
|
||||
@@ -825,11 +1002,16 @@ export const EN: TranslationSchema = {
|
||||
listInvoke:
|
||||
"Invoke a skill with `/skill <name> [args]` or by asking the model to call `run_skill`.",
|
||||
listHeader: "User skills ({count}):",
|
||||
listFooter: "View body: /skill show <name> Run: /skill <name> [args]",
|
||||
listFooter: "View: /skill show <name> Run: /skill <name> [args] New: /skill new <name>",
|
||||
listEmptyNewHint:
|
||||
"Scaffold one with: /skill new <name> (project scope) — there's no remote registry yet; you author skills directly.",
|
||||
showUsage: "usage: /skill show <name>",
|
||||
showNotFound: "no skill found: {name}",
|
||||
runNotFound: "no skill found: {name} (try /skill list)",
|
||||
runInfo: "▸ running skill: {name}{args}",
|
||||
newUsage: "usage: /skill new <name> [--global]",
|
||||
newCreated: "▸ created skill: {name}\n {path}\n edit it, then `/skill {name}` to invoke",
|
||||
newError: "▲ /skill new failed: {reason}",
|
||||
},
|
||||
},
|
||||
};
|
||||
+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;
|
||||
};
|
||||
}
|
||||
+181
-8
@@ -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: {
|
||||
@@ -653,6 +792,7 @@ export const zhCN: TranslationSchema = {
|
||||
statusBudget: " 预算 ${spent} / ${cap}({pct}%){tag}",
|
||||
statusSession: ' 会话 "{name}" · 日志中 {count} 条消息(恢复了 {resumed} 条)',
|
||||
statusSessionEphemeral: " 会话 (临时 — 无持久化)",
|
||||
statusWorkspace: " 工作区 {path} · 启动时锁定(用 --dir <path> 重新启动以切换)",
|
||||
statusMcp: " MCP {servers} 个服务器,注册表中 {tools} 个工具",
|
||||
statusEdits: " 编辑 {count} 个待处理(/apply 提交,/discard 丢弃)",
|
||||
statusPlan: " 计划 开启 — 写入受限(submit_plan + 审批)",
|
||||
@@ -674,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` 中可用。",
|
||||
@@ -742,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:
|
||||
@@ -752,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:
|
||||
@@ -761,11 +929,16 @@ export const zhCN: TranslationSchema = {
|
||||
listFrontmatter: "每个文件的 frontmatter 至少需要 `name` 和 `description`。",
|
||||
listInvoke: "使用 `/skill <name> [args]` 调用技能,或让模型调用 `run_skill`。",
|
||||
listHeader: "用户技能({count}):",
|
||||
listFooter: "查看正文:/skill show <name> 运行:/skill <name> [args]",
|
||||
listFooter: "查看:/skill show <name> 运行:/skill <name> [args] 新建:/skill new <name>",
|
||||
listEmptyNewHint:
|
||||
"用 `/skill new <name>` 在项目范围下生成一个空白模板 — 暂无在线市场,技能需要自己写。",
|
||||
showUsage: "用法:/skill show <name>",
|
||||
showNotFound: "未找到技能:{name}",
|
||||
runNotFound: "未找到技能:{name} (尝试 /skill list)",
|
||||
runInfo: "▸ 正在运行技能:{name}{args}",
|
||||
newUsage: "用法:/skill new <name> [--global]",
|
||||
newCreated: "▸ 已创建技能:{name}\n {path}\n 编辑后用 `/skill {name}` 调用",
|
||||
newError: "▲ /skill new 失败:{reason}",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -117,6 +117,7 @@ export {
|
||||
formatSearchResults,
|
||||
htmlToText,
|
||||
parseMojeekResults,
|
||||
parseSearxngHtmlResults,
|
||||
registerWebTools,
|
||||
webFetch,
|
||||
webSearch,
|
||||
|
||||
+77
-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,
|
||||
@@ -226,9 +227,15 @@ export class CacheFirstLoop {
|
||||
}
|
||||
return def.readOnly !== true;
|
||||
};
|
||||
const isStormExempt = (call: ToolCall): boolean => {
|
||||
const name = call.function?.name;
|
||||
if (!name) return false;
|
||||
return registry.get(name)?.stormExempt === true;
|
||||
};
|
||||
this.repair = new ToolCallRepair({
|
||||
allowedToolNames: allowedNames,
|
||||
isMutating,
|
||||
isStormExempt,
|
||||
stormThreshold: parsePositiveIntEnv(process.env.REASONIX_STORM_THRESHOLD),
|
||||
stormWindow: parsePositiveIntEnv(process.env.REASONIX_STORM_WINDOW),
|
||||
});
|
||||
@@ -252,6 +259,9 @@ export class CacheFirstLoop {
|
||||
this.stats.seedCarryover({
|
||||
totalCostUsd: meta.totalCostUsd,
|
||||
turnCount: meta.turnCount,
|
||||
cacheHitTokens: meta.cacheHitTokens,
|
||||
cacheMissTokens: meta.cacheMissTokens,
|
||||
lastPromptTokens: meta.lastPromptTokens,
|
||||
});
|
||||
}
|
||||
if (healedCount > 0) {
|
||||
@@ -388,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;
|
||||
}
|
||||
@@ -499,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;
|
||||
}
|
||||
@@ -508,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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -553,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;
|
||||
@@ -582,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.]";
|
||||
@@ -628,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) {
|
||||
@@ -636,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);
|
||||
@@ -654,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);
|
||||
@@ -671,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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -939,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;
|
||||
}
|
||||
@@ -968,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
|
||||
@@ -1013,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
|
||||
@@ -1055,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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1088,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;
|
||||
}
|
||||
@@ -1097,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",
|
||||
@@ -1122,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") {
|
||||
@@ -1144,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" });
|
||||
@@ -1230,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,23 @@
|
||||
import { type Stats, statSync } from "node:fs";
|
||||
import type { StdioMcpSpec } from "./spec.js";
|
||||
|
||||
const FILESYSTEM_PKG = "@modelcontextprotocol/server-filesystem";
|
||||
|
||||
export function preflightStdioSpec(spec: StdioMcpSpec): void {
|
||||
const pkgIndex = spec.args.indexOf(FILESYSTEM_PKG);
|
||||
if (pkgIndex < 0) return;
|
||||
const positional = spec.args.slice(pkgIndex + 1).filter((a) => !a.startsWith("-"));
|
||||
for (const dir of positional) {
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = statSync(dir);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`MCP filesystem sandbox '${dir}' does not exist — create it with: mkdir -p '${dir}'`,
|
||||
);
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`MCP filesystem sandbox '${dir}' exists but is not a directory`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,13 @@ export interface SessionMeta {
|
||||
turnCount?: number;
|
||||
/** Absolute path of the workspace root the session was created/used in. */
|
||||
workspace?: string;
|
||||
/** Wallet currency at last save — used to format `totalCostUsd` in the picker without re-fetching balance. */
|
||||
balanceCurrency?: string;
|
||||
/** Cumulative cache hit / miss tokens across the session — survives resume so /status cache% isn't 0 on a fresh boot. */
|
||||
cacheHitTokens?: number;
|
||||
cacheMissTokens?: number;
|
||||
/** Last turn's promptTokens — lets /status render the context bar before the next turn fires. */
|
||||
lastPromptTokens?: number;
|
||||
}
|
||||
|
||||
export function sessionsDir(): string {
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import type { CharPool } from "../pools/char-pool.js";
|
||||
import type { HyperlinkPool } from "../pools/hyperlink-pool.js";
|
||||
import type { StylePool } from "../pools/style-pool.js";
|
||||
import { type Cell, CellWidth } from "../screen/cell.js";
|
||||
import { diffEach } from "../screen/diff.js";
|
||||
import type { Frame } from "./frame.js";
|
||||
import type { Patch } from "./patch.js";
|
||||
|
||||
export interface DiffPools {
|
||||
readonly char: CharPool;
|
||||
readonly style: StylePool;
|
||||
readonly hyperlink: HyperlinkPool;
|
||||
}
|
||||
|
||||
interface CursorState {
|
||||
x: number;
|
||||
y: number;
|
||||
styleId: number;
|
||||
hyperlinkId: number;
|
||||
}
|
||||
|
||||
export function diffFrames(prev: Frame, next: Frame, pools: DiffPools): Patch[] {
|
||||
if (prev.viewportWidth !== next.viewportWidth || prev.viewportHeight !== next.viewportHeight) {
|
||||
return fullReset(next, pools);
|
||||
}
|
||||
|
||||
const out: Patch[] = [];
|
||||
const cursor: CursorState = {
|
||||
x: prev.cursor.x,
|
||||
y: prev.cursor.y,
|
||||
styleId: pools.style.none,
|
||||
hyperlinkId: 0,
|
||||
};
|
||||
|
||||
diffEach(prev.screen, next.screen, (x, y, _prevCell, nextCell) => {
|
||||
moveTo(out, cursor, x, y, next.viewportWidth);
|
||||
writeCell(out, cursor, nextCell, pools);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
appendTrailClears(out, cursor, prev, next);
|
||||
|
||||
if (next.cursor.x !== cursor.x || next.cursor.y !== cursor.y) {
|
||||
moveTo(out, cursor, next.cursor.x, next.cursor.y, next.viewportWidth);
|
||||
}
|
||||
|
||||
if (prev.cursor.visible !== next.cursor.visible) {
|
||||
out.push({ type: "cursorVisible", visible: next.cursor.visible });
|
||||
}
|
||||
|
||||
resetTrailingState(out, cursor, pools);
|
||||
return out;
|
||||
}
|
||||
|
||||
function fullReset(next: Frame, pools: DiffPools): Patch[] {
|
||||
const out: Patch[] = [{ type: "clearTerminal" }];
|
||||
const cursor: CursorState = { x: 0, y: 0, styleId: pools.style.none, hyperlinkId: 0 };
|
||||
for (let y = 0; y < next.screen.height; y++) {
|
||||
for (let x = 0; x < next.screen.width; x++) {
|
||||
const cell = next.screen.cellAt(x, y);
|
||||
if (!cell) continue;
|
||||
if (cell.width === CellWidth.SpacerTail) continue;
|
||||
moveTo(out, cursor, x, y, next.viewportWidth);
|
||||
writeCell(out, cursor, cell, pools);
|
||||
}
|
||||
}
|
||||
resetTrailingState(out, cursor, pools);
|
||||
return out;
|
||||
}
|
||||
|
||||
function moveTo(
|
||||
out: Patch[],
|
||||
cursor: CursorState,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
viewportWidth: number,
|
||||
): void {
|
||||
if (cursor.x === targetX && cursor.y === targetY) return;
|
||||
|
||||
if (cursor.x >= viewportWidth) {
|
||||
out.push({ type: "carriageReturn" });
|
||||
cursor.x = 0;
|
||||
}
|
||||
|
||||
if (cursor.y !== targetY) {
|
||||
const dy = targetY - cursor.y;
|
||||
out.push({ type: "carriageReturn" });
|
||||
out.push({ type: "cursorMove", dx: 0, dy });
|
||||
cursor.x = 0;
|
||||
cursor.y = targetY;
|
||||
}
|
||||
|
||||
if (cursor.x !== targetX) {
|
||||
out.push({ type: "cursorTo", col: targetX });
|
||||
cursor.x = targetX;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCell(
|
||||
out: Patch[],
|
||||
cursor: CursorState,
|
||||
cell: Cell | undefined,
|
||||
pools: DiffPools,
|
||||
): void {
|
||||
if (!cell) {
|
||||
transitionStyle(out, cursor, pools.style.none, pools);
|
||||
transitionHyperlink(out, cursor, 0, pools);
|
||||
out.push({ type: "stdout", content: " " });
|
||||
cursor.x++;
|
||||
return;
|
||||
}
|
||||
if (cell.width === CellWidth.SpacerTail) return;
|
||||
|
||||
transitionStyle(out, cursor, cell.styleId, pools);
|
||||
transitionHyperlink(out, cursor, cell.hyperlinkId, pools);
|
||||
const isWide = cell.width === CellWidth.Wide;
|
||||
const charStr = pools.char.get(cell.charId);
|
||||
if (isWide) {
|
||||
// Compensation for terminal-vs-layout width disagreement on East Asian
|
||||
// Ambiguous chars: pre-write a space at col+1 (so it's blank on Western
|
||||
// terms where the glyph renders only 1 wide), then jump back, write the
|
||||
// char, then force the cursor to col+2 regardless of how many cells the
|
||||
// terminal actually advanced. Borrowed from Claude Code's logUpdate.
|
||||
out.push({ type: "cursorTo", col: cursor.x + 1 });
|
||||
out.push({ type: "stdout", content: " " });
|
||||
out.push({ type: "cursorTo", col: cursor.x });
|
||||
out.push({ type: "stdout", content: charStr });
|
||||
cursor.x += 2;
|
||||
out.push({ type: "cursorTo", col: cursor.x });
|
||||
} else {
|
||||
out.push({ type: "stdout", content: charStr });
|
||||
cursor.x += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function transitionStyle(
|
||||
out: Patch[],
|
||||
cursor: CursorState,
|
||||
targetStyleId: number,
|
||||
pools: DiffPools,
|
||||
): void {
|
||||
if (cursor.styleId === targetStyleId) return;
|
||||
const str = pools.style.transition(cursor.styleId, targetStyleId);
|
||||
if (str.length > 0) out.push({ type: "styleStr", str });
|
||||
cursor.styleId = targetStyleId;
|
||||
}
|
||||
|
||||
function transitionHyperlink(
|
||||
out: Patch[],
|
||||
cursor: CursorState,
|
||||
targetId: number,
|
||||
pools: DiffPools,
|
||||
): void {
|
||||
if (cursor.hyperlinkId === targetId) return;
|
||||
out.push({ type: "hyperlink", uri: pools.hyperlink.get(targetId) ?? "" });
|
||||
cursor.hyperlinkId = targetId;
|
||||
}
|
||||
|
||||
function resetTrailingState(out: Patch[], cursor: CursorState, pools: DiffPools): void {
|
||||
transitionStyle(out, cursor, pools.style.none, pools);
|
||||
transitionHyperlink(out, cursor, 0, pools);
|
||||
}
|
||||
|
||||
/** Per-row trail clear when prev had content past next's last visible cell — defends shrinking rows against terminal-state desync. */
|
||||
function appendTrailClears(out: Patch[], cursor: CursorState, prev: Frame, next: Frame): void {
|
||||
const w = Math.min(prev.screen.width, next.viewportWidth);
|
||||
const h = Math.max(prev.screen.height, next.screen.height);
|
||||
for (let y = 0; y < h; y++) {
|
||||
let lastNextCol = -1;
|
||||
for (let x = next.screen.width - 1; x >= 0; x--) {
|
||||
const c = next.screen.cellAt(x, y);
|
||||
if (c && c.charId !== 0) {
|
||||
lastNextCol = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let prevHasTrail = false;
|
||||
for (let x = lastNextCol + 1; x < w; x++) {
|
||||
const c = prev.screen.cellAt(x, y);
|
||||
if (c && c.charId !== 0) {
|
||||
prevHasTrail = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!prevHasTrail) continue;
|
||||
moveTo(out, cursor, Math.max(0, lastNextCol + 1), y, next.viewportWidth);
|
||||
out.push({ type: "clearToEOL" });
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Screen } from "../screen/screen.js";
|
||||
|
||||
export interface Cursor {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly visible: boolean;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
readonly screen: Screen;
|
||||
readonly viewportWidth: number;
|
||||
readonly viewportHeight: number;
|
||||
readonly cursor: Cursor;
|
||||
}
|
||||
|
||||
export function emptyFrame(viewportWidth: number, viewportHeight: number): Frame {
|
||||
return {
|
||||
screen: new Screen(0, 0),
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
cursor: { x: 0, y: 0, visible: true },
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export type Patch =
|
||||
| { readonly type: "stdout"; readonly content: string }
|
||||
| { readonly type: "cursorMove"; readonly dx: number; readonly dy: number }
|
||||
| { readonly type: "cursorTo"; readonly col: number }
|
||||
| { readonly type: "cursorVisible"; readonly visible: boolean }
|
||||
| { readonly type: "carriageReturn" }
|
||||
| { readonly type: "styleStr"; readonly str: string }
|
||||
| { readonly type: "hyperlink"; readonly uri: string }
|
||||
| { readonly type: "clear"; readonly count: number }
|
||||
| { readonly type: "clearToEOL" }
|
||||
| { readonly type: "clearTerminal" };
|
||||
|
||||
export type Diff = ReadonlyArray<Patch>;
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { Diff, Patch } from "./patch.js";
|
||||
|
||||
const ESC = "\x1b";
|
||||
const CSI = `${ESC}[`;
|
||||
const ST = `${ESC}\\`;
|
||||
|
||||
export function serializePatches(patches: Diff): string {
|
||||
let out = "";
|
||||
for (const patch of patches) {
|
||||
out += serializeOne(patch);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serializeOne(patch: Patch): string {
|
||||
switch (patch.type) {
|
||||
case "stdout":
|
||||
return patch.content;
|
||||
case "cursorMove":
|
||||
return cursorMove(patch.dx, patch.dy);
|
||||
case "cursorTo":
|
||||
return `${CSI}${patch.col + 1}G`;
|
||||
case "cursorVisible":
|
||||
return patch.visible ? `${CSI}?25h` : `${CSI}?25l`;
|
||||
case "carriageReturn":
|
||||
return "\r";
|
||||
case "styleStr":
|
||||
return patch.str;
|
||||
case "hyperlink":
|
||||
return `${ESC}]8;;${patch.uri}${ST}`;
|
||||
case "clear":
|
||||
return clearLines(patch.count);
|
||||
case "clearToEOL":
|
||||
return `${CSI}K`;
|
||||
case "clearTerminal":
|
||||
return `${CSI}2J${CSI}H`;
|
||||
}
|
||||
}
|
||||
|
||||
function cursorMove(dx: number, dy: number): string {
|
||||
let out = "";
|
||||
if (dy > 0) out += `${CSI}${dy}B`;
|
||||
else if (dy < 0) out += `${CSI}${-dy}A`;
|
||||
if (dx > 0) out += `${CSI}${dx}C`;
|
||||
else if (dx < 0) out += `${CSI}${-dx}D`;
|
||||
return out;
|
||||
}
|
||||
|
||||
function clearLines(count: number): string {
|
||||
if (count <= 0) return `\r${CSI}J`;
|
||||
return `\r${CSI}${count}A${CSI}J`;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export { CharPool } from "./pools/char-pool.js";
|
||||
export { HyperlinkPool } from "./pools/hyperlink-pool.js";
|
||||
export { StylePool, type AnsiCode } from "./pools/style-pool.js";
|
||||
export { type Cell, CellWidth, EMPTY_CELL, cellsEqual } from "./screen/cell.js";
|
||||
export { type Rectangle, Screen } from "./screen/screen.js";
|
||||
export { type DiffCallback, diffEach } from "./screen/diff.js";
|
||||
export type { BorderStyle, BorderStyleName } from "./layout/borders.js";
|
||||
export type { BoxNode, LayoutNode, TextNode } from "./layout/node.js";
|
||||
export { type RenderPools, renderToScreen } from "./layout/layout.js";
|
||||
export { Box, type BoxProps, Text, type TextProps } from "./react/components.js";
|
||||
export { type RenderOptions, render } from "./react/render.js";
|
||||
export { type Cursor, type Frame, emptyFrame } from "./diff/frame.js";
|
||||
export type { Diff, Patch } from "./diff/patch.js";
|
||||
export { type DiffPools, diffFrames } from "./diff/diff-frames.js";
|
||||
export { serializePatches } from "./diff/serialize.js";
|
||||
export { Renderer, type RendererOptions } from "./runtime/renderer.js";
|
||||
export { renderToBytes } from "./runtime/render-to-bytes.js";
|
||||
export { type TestWriter, makeTestWriter } from "./runtime/test-writer.js";
|
||||
export { type Handle, type MountOptions, mount } from "./reconciler/mount.js";
|
||||
export {
|
||||
CursorContext,
|
||||
type CursorSetter,
|
||||
type CursorTarget,
|
||||
useCursor,
|
||||
} from "./reconciler/cursor.js";
|
||||
export {
|
||||
type Keystroke,
|
||||
KeystrokeContext,
|
||||
KeystrokeReader,
|
||||
type KeystrokeListener,
|
||||
type KeystrokeReaderOptions,
|
||||
type KeystrokeSource,
|
||||
emptyKeystroke,
|
||||
parseKeystrokes,
|
||||
useKeystroke,
|
||||
} from "./input/index.js";
|
||||
export * as inkCompat from "./ink-compat/index.js";
|
||||
@@ -1,110 +0,0 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { type ReactNode } from "react";
|
||||
import type { BorderStyle, BorderStyleName } from "../layout/borders.js";
|
||||
import type { AlignItems, FlexWrap, JustifyContent } from "../layout/node.js";
|
||||
import { Box as RsxBox } from "../react/components.js";
|
||||
import { fgCode } from "./colors.js";
|
||||
|
||||
export interface InkBoxProps {
|
||||
readonly children?: ReactNode;
|
||||
readonly flexDirection?: "column" | "row" | "column-reverse" | "row-reverse";
|
||||
readonly flexGrow?: number;
|
||||
readonly flexShrink?: number;
|
||||
readonly flexBasis?: number | `${number}%` | "auto";
|
||||
readonly flexWrap?: FlexWrap;
|
||||
readonly padding?: number;
|
||||
readonly paddingX?: number;
|
||||
readonly paddingY?: number;
|
||||
readonly paddingTop?: number;
|
||||
readonly paddingBottom?: number;
|
||||
readonly paddingLeft?: number;
|
||||
readonly paddingRight?: number;
|
||||
readonly margin?: number;
|
||||
readonly marginX?: number;
|
||||
readonly marginY?: number;
|
||||
readonly marginTop?: number;
|
||||
readonly marginBottom?: number;
|
||||
readonly marginLeft?: number;
|
||||
readonly marginRight?: number;
|
||||
readonly borderStyle?: BorderStyle | BorderStyleName;
|
||||
readonly borderColor?: string;
|
||||
readonly borderTopColor?: string;
|
||||
readonly borderBottomColor?: string;
|
||||
readonly borderLeftColor?: string;
|
||||
readonly borderRightColor?: string;
|
||||
readonly borderTop?: boolean;
|
||||
readonly borderBottom?: boolean;
|
||||
readonly borderLeft?: boolean;
|
||||
readonly borderRight?: boolean;
|
||||
readonly width?: number | `${number}%`;
|
||||
readonly height?: number | `${number}%`;
|
||||
readonly minWidth?: number | `${number}%`;
|
||||
readonly minHeight?: number | `${number}%`;
|
||||
readonly maxWidth?: number | `${number}%`;
|
||||
readonly maxHeight?: number | `${number}%`;
|
||||
readonly justifyContent?: JustifyContent;
|
||||
readonly alignItems?: AlignItems;
|
||||
readonly alignSelf?: AlignItems;
|
||||
readonly alignContent?: AlignItems | "space-between" | "space-around";
|
||||
readonly gap?: number;
|
||||
readonly columnGap?: number;
|
||||
readonly rowGap?: number;
|
||||
readonly display?: "flex" | "none";
|
||||
}
|
||||
|
||||
export function Box(props: InkBoxProps): React.ReactElement {
|
||||
const borderColor = fgCode(props.borderColor);
|
||||
const borderTopColor = fgCode(props.borderTopColor);
|
||||
const borderBottomColor = fgCode(props.borderBottomColor);
|
||||
const borderLeftColor = fgCode(props.borderLeftColor);
|
||||
const borderRightColor = fgCode(props.borderRightColor);
|
||||
return (
|
||||
<RsxBox
|
||||
flexDirection={props.flexDirection ?? "row"}
|
||||
flexGrow={props.flexGrow}
|
||||
flexShrink={props.flexShrink}
|
||||
flexBasis={props.flexBasis}
|
||||
flexWrap={props.flexWrap}
|
||||
justifyContent={props.justifyContent}
|
||||
alignItems={props.alignItems}
|
||||
alignSelf={props.alignSelf}
|
||||
alignContent={props.alignContent}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
minWidth={props.minWidth}
|
||||
minHeight={props.minHeight}
|
||||
maxWidth={props.maxWidth}
|
||||
maxHeight={props.maxHeight}
|
||||
gap={props.gap}
|
||||
columnGap={props.columnGap}
|
||||
rowGap={props.rowGap}
|
||||
display={props.display}
|
||||
padding={props.padding}
|
||||
paddingX={props.paddingX}
|
||||
paddingY={props.paddingY}
|
||||
paddingTop={props.paddingTop}
|
||||
paddingBottom={props.paddingBottom}
|
||||
paddingLeft={props.paddingLeft}
|
||||
paddingRight={props.paddingRight}
|
||||
margin={props.margin}
|
||||
marginX={props.marginX}
|
||||
marginY={props.marginY}
|
||||
marginTop={props.marginTop}
|
||||
marginBottom={props.marginBottom}
|
||||
marginLeft={props.marginLeft}
|
||||
marginRight={props.marginRight}
|
||||
borderStyle={props.borderStyle}
|
||||
borderColor={borderColor ? [borderColor] : undefined}
|
||||
borderTopColor={borderTopColor ? [borderTopColor] : undefined}
|
||||
borderBottomColor={borderBottomColor ? [borderBottomColor] : undefined}
|
||||
borderLeftColor={borderLeftColor ? [borderLeftColor] : undefined}
|
||||
borderRightColor={borderRightColor ? [borderRightColor] : undefined}
|
||||
borderTop={props.borderTop}
|
||||
borderBottom={props.borderBottom}
|
||||
borderLeft={props.borderLeft}
|
||||
borderRight={props.borderRight}
|
||||
>
|
||||
{props.children}
|
||||
</RsxBox>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { Box } from "../react/components.js";
|
||||
|
||||
export function Spacer(): React.ReactElement {
|
||||
return <Box flexGrow={1} />;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { type ReactNode, useContext, useEffect, useRef } from "react";
|
||||
import { Box } from "../react/components.js";
|
||||
import { RendererBridgeContext } from "./renderer-bridge.js";
|
||||
|
||||
export interface StaticProps<T> {
|
||||
readonly items: ReadonlyArray<T>;
|
||||
readonly children: (item: T, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
export function Static<T>(props: StaticProps<T>): React.ReactElement | null {
|
||||
const bridge = useContext(RendererBridgeContext);
|
||||
const lastIndex = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bridge) return;
|
||||
if (props.items.length < lastIndex.current) {
|
||||
lastIndex.current = props.items.length;
|
||||
return;
|
||||
}
|
||||
if (props.items.length === lastIndex.current) return;
|
||||
const fresh = props.items.slice(lastIndex.current);
|
||||
const startIndex = lastIndex.current;
|
||||
lastIndex.current = props.items.length;
|
||||
bridge.emitStatic(
|
||||
<Box flexDirection="column">
|
||||
{fresh.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Static items are positional and append-only
|
||||
<React.Fragment key={`s-${startIndex + i}`}>
|
||||
{props.children(item, startIndex + i)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>,
|
||||
);
|
||||
}, [bridge, props.items, props.children]);
|
||||
|
||||
if (bridge) return null;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{props.items.map((item, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fallback path with no bridge — positional keys are fine
|
||||
<React.Fragment key={`s-${i}`}>{props.children(item, i)}</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { type ReactNode } from "react";
|
||||
import type { AnsiCode } from "../pools/style-pool.js";
|
||||
import { Text as RsxText } from "../react/components.js";
|
||||
import { bgCode, fgCode } from "./colors.js";
|
||||
|
||||
export interface InkTextProps {
|
||||
readonly children?: ReactNode;
|
||||
readonly color?: string;
|
||||
readonly backgroundColor?: string;
|
||||
readonly bold?: boolean;
|
||||
readonly dimColor?: boolean;
|
||||
readonly italic?: boolean;
|
||||
readonly underline?: boolean;
|
||||
readonly strikethrough?: boolean;
|
||||
readonly inverse?: boolean;
|
||||
readonly wrap?: "wrap" | "truncate" | "truncate-start" | "truncate-middle" | "truncate-end";
|
||||
/** OSC 8 hyperlink target — emitted as proper escape bytes, never inlined into cell content. */
|
||||
readonly hyperlink?: string;
|
||||
}
|
||||
|
||||
const SGR = {
|
||||
bold: { apply: "\x1b[1m", revert: "\x1b[22m" },
|
||||
dim: { apply: "\x1b[2m", revert: "\x1b[22m" },
|
||||
italic: { apply: "\x1b[3m", revert: "\x1b[23m" },
|
||||
underline: { apply: "\x1b[4m", revert: "\x1b[24m" },
|
||||
inverse: { apply: "\x1b[7m", revert: "\x1b[27m" },
|
||||
strikethrough: { apply: "\x1b[9m", revert: "\x1b[29m" },
|
||||
} as const;
|
||||
|
||||
export function Text(props: InkTextProps): React.ReactElement {
|
||||
const codes: AnsiCode[] = [];
|
||||
const fg = fgCode(props.color);
|
||||
if (fg) codes.push(fg);
|
||||
const bg = bgCode(props.backgroundColor);
|
||||
if (bg) codes.push(bg);
|
||||
if (props.bold) codes.push(SGR.bold);
|
||||
if (props.dimColor) codes.push(SGR.dim);
|
||||
if (props.italic) codes.push(SGR.italic);
|
||||
if (props.underline) codes.push(SGR.underline);
|
||||
if (props.inverse) codes.push(SGR.inverse);
|
||||
if (props.strikethrough) codes.push(SGR.strikethrough);
|
||||
return (
|
||||
<RsxText style={codes.length > 0 ? codes : undefined} hyperlink={props.hyperlink}>
|
||||
{props.children}
|
||||
</RsxText>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { AnsiCode } from "../pools/style-pool.js";
|
||||
|
||||
export type ColorName =
|
||||
| "black"
|
||||
| "red"
|
||||
| "green"
|
||||
| "yellow"
|
||||
| "blue"
|
||||
| "magenta"
|
||||
| "cyan"
|
||||
| "white"
|
||||
| "gray"
|
||||
| "grey"
|
||||
| "blackBright"
|
||||
| "redBright"
|
||||
| "greenBright"
|
||||
| "yellowBright"
|
||||
| "blueBright"
|
||||
| "magentaBright"
|
||||
| "cyanBright"
|
||||
| "whiteBright";
|
||||
|
||||
const FG_BASE: Record<ColorName, number> = {
|
||||
black: 30,
|
||||
red: 31,
|
||||
green: 32,
|
||||
yellow: 33,
|
||||
blue: 34,
|
||||
magenta: 35,
|
||||
cyan: 36,
|
||||
white: 37,
|
||||
gray: 90,
|
||||
grey: 90,
|
||||
blackBright: 90,
|
||||
redBright: 91,
|
||||
greenBright: 92,
|
||||
yellowBright: 93,
|
||||
blueBright: 94,
|
||||
magentaBright: 95,
|
||||
cyanBright: 96,
|
||||
whiteBright: 97,
|
||||
};
|
||||
|
||||
const FG_RESET = 39;
|
||||
const BG_RESET = 49;
|
||||
|
||||
export function fgCode(color: string | undefined): AnsiCode | null {
|
||||
if (!color) return null;
|
||||
if (color.startsWith("#")) {
|
||||
const rgb = parseHex(color);
|
||||
if (!rgb) return null;
|
||||
return { apply: `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`, revert: `\x1b[${FG_RESET}m` };
|
||||
}
|
||||
const code = FG_BASE[color as ColorName];
|
||||
if (code === undefined) return null;
|
||||
return { apply: `\x1b[${code}m`, revert: `\x1b[${FG_RESET}m` };
|
||||
}
|
||||
|
||||
export function bgCode(color: string | undefined): AnsiCode | null {
|
||||
if (!color) return null;
|
||||
if (color.startsWith("#")) {
|
||||
const rgb = parseHex(color);
|
||||
if (!rgb) return null;
|
||||
return { apply: `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`, revert: `\x1b[${BG_RESET}m` };
|
||||
}
|
||||
const base = FG_BASE[color as ColorName];
|
||||
if (base === undefined) return null;
|
||||
const bg = base < 90 ? base + 10 : base + 10;
|
||||
return { apply: `\x1b[${bg}m`, revert: `\x1b[${BG_RESET}m` };
|
||||
}
|
||||
|
||||
function parseHex(hex: string): [number, number, number] | null {
|
||||
const s = hex.slice(1);
|
||||
if (s.length === 3) {
|
||||
const r = Number.parseInt(s[0]! + s[0]!, 16);
|
||||
const g = Number.parseInt(s[1]! + s[1]!, 16);
|
||||
const b = Number.parseInt(s[2]! + s[2]!, 16);
|
||||
return Number.isNaN(r) ? null : [r, g, b];
|
||||
}
|
||||
if (s.length === 6) {
|
||||
const r = Number.parseInt(s.slice(0, 2), 16);
|
||||
const g = Number.parseInt(s.slice(2, 4), 16);
|
||||
const b = Number.parseInt(s.slice(4, 6), 16);
|
||||
return Number.isNaN(r) ? null : [r, g, b];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user