Compare commits

...
40 Commits
Author SHA1 Message Date
YHHandreasonix 6ea352fdd8 chore(changelog): document #1563 + #1565 under 0.49.0 (#1569)
read-before-edit gate (#1563) and cache-aligned fold summary (#1565)
landed after the release commit was written. Document them in the
0.49.0 entry before tagging so the published CHANGELOG matches what
ships.

Co-authored-by: reasonix <[email protected]>
2026-05-22 10:11:59 -07:00
YHHandreasonix 544714b2ba fix(context): align fold summary prefix with main agent for cache reuse (#1565)
* chore(release): 0.49.0 — static-history TUI, queued steers, Bing default, lifecycle plans

Headline themes:
- TUI: Static-history renderer is the only path; virtual-viewport layers removed (#1529 stages 1-4)
- Chat: queued mid-turn steer handling so input mid-render doesn't drop or fight the live frame (#1501)
- Web search: default switches to Bing; dashboard engine switcher; Mojeek dropped (#1558)
- Plans: lifecycle evidence summaries surface why a plan is ready to accept (#1500)
- Desktop: native OS notifications for approvals + completion (#1519)
- i18n: CLI command output (/mcp /sessions /prune /theme) + approval-prompt labels translated (#1524, #1560)
- Security: SSRF block in web_fetch (#1544), edit-snapshot path containment (#1454), shell redirect sandbox (#1457), Task integrity guardrail (#1516)
- Tools: per-turn dispatch-rate limit (#1356); run_command discourages shell-based edits (#1514)
- Client: DeepSeek 429 → concurrency-limit hint (#1526); timeoutMs honored with AbortSignal (#1535); --no-proxy opt-out for direct route (#1507)
- Files: read/edit/restore preserves source encoding (GB18030 / UTF-8 BOM) (#1518)
- Context: pinned constraints survive folds + full tail capture (#1515, #1552)
- Refactor: lifecycle risk policy extracted into its own module (#1557)

See CHANGELOG for the full list.

* fix(context): align fold summary prefix with main agent for cache reuse

The summarizer call was sending a bespoke "You compress conversation
history" system prompt and no tools, guaranteeing a 0% cache hit
against the main agent's just-cached prefix. Reshape the request so
system + tools + head bytes mirror the live agent's last call — the
only novel bytes are the trailing summarize instruction.

Skill-pin handling now collects bodies read-only instead of stubbing
mid-head, so the cache prefix stays unbroken. The summarize
instruction names pinned skills so the model knows not to paraphrase
their bodies (which we append verbatim regardless).

Measured on a real session at 48.7K prompt tokens:
  OLD shape: 0.0% cache hit  → $0.145 per fold
  NEW shape: 99.6% cache hit → $0.015 per fold
  saving: 89.6% per fold

* tools: add fold-cache shape + live benchmarks

bench-fold-cache-shape.mjs replays real session jsonls, simulates
OLD vs NEW summary-call shapes at the fold point, and reports
byte-level shared-prefix with the main agent's preceding request.
Pure local — no API required.

bench-fold-cache-live.mjs sends one priming + two summary calls to
DeepSeek and reports prompt_cache_hit_tokens / cost for each shape.
Used to confirm the shape change actually translates to API-side
cache hits.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-22 09:20:25 -07:00
YHHandreasonix 7ae935335e feat(edit): enforce read-before-edit at the dispatch gate (#1563)
Soft prompt hint ("read_file first so SEARCH matches byte-for-byte")
was easy for the model to skip — the failure mode is a tool round
spent on "search text not found", a guess at the bytes, and a retry.
Each round is a cache-miss tail and an obviously-not-grounded edit
that erodes trust.

New ReadTracker lives on CacheFirstLoop and threads through
ToolCallContext. read_file marks the absolute path on any successful
load (full, range, head, tail, outline); write_file marks too — the
model knows what it just wrote. edit_file / multi_edit consult the
tracker before applying and refuse unread targets with a message
that names the path and tells the model to call read_file first.
multi_edit refuses the whole batch if any target is unread, so a
partial-knowledge cross-file refactor can't half-apply.

ContextManager fires a new onLogRewrite callback from fold and
mechanicalTruncate; the loop wires it to ReadTracker.reset() because
once the byte-level history is folded away the model's "I saw it"
claim no longer holds. The tracker is in-memory only, so session
resume also starts empty — the model must re-read before editing
after a restart.

tools.ts's repeat-rejection detector recognizes the "read_file first"
gate so a 2nd identical unread edit gets the sharper "do not retry
identical args — call read_file on the target path first" hint
instead of the same refusal echoed back.

Path normalization lower-cases on win32 so case variants of the same
file resolve to one tracker entry.

11 new tests in tests/read-before-edit-gate.test.ts cover: refusal,
post-read success, write_file-counts-as-read, partial-read accepted,
multi_edit all-or-nothing, repeat-rejection sharpening, reset
semantics, and backwards compat when no tracker is injected.

Co-authored-by: reasonix <[email protected]>
2026-05-22 08:36:09 -07:00
YHHandreasonix 77a7cd9c61 fix(approval-prompts): translate Shell/Path confirm title and action labels (#1560) (#1561)
`toApprovalPrompt` in core-utils mints English labels so the ACP surface
(no i18n layer) can render immediately — but the CLI surface was rendering
those English strings verbatim, leaving "Run command" / "Run once" /
"Always allow — …" / "Deny" on screen even when the locale was zh-CN.

ShellConfirm and PathConfirm now override `prompt.title` and each
`action.label` with locally-translated strings keyed off `action.id` and
`prompt.data` (so we can still tell foreground vs background and read vs
write apart, even after the unified `prompt.kind` collapses each pair).

New i18n keys in both EN.ts and zh-CN.ts:

- `shellConfirm.promptTitleRunCommand` / `promptTitleRunBackground`
- `shellConfirm.actionRunOnce` / `actionAlwaysAllow` (with `{prefix}`) / `actionDeny`
- `pathConfirm.promptTitleRead` / `promptTitleWrite`
- `pathConfirm.actionAllowRead` / `actionAllowWrite` / `actionAlwaysAllow` / `actionDeny`

Test plan:
- `tests/shell-confirm-render.test.tsx` gains a `setLanguageRuntime("zh-CN")`
  case that asserts 运行命令 / 运行一次 / 始终允许 / 拒绝 render and the
  English labels do NOT survive.
- `npm run verify` passes (3554 tests).

Closes #1560

Co-authored-by: reasonix <[email protected]>
2026-05-22 08:26:21 -07:00
TaoMu 615cc81fce refactor(lifecycle): extract risk policy (#1557) 2026-05-22 08:07:14 -07:00
YHHandreasonix 65c0d641ef feat(web-search): default to Bing, drop Mojeek, dashboard engine switcher (#1558)
* feat(web-search): default to Bing (cn.bing.com), drop Mojeek, dashboard switcher

Mojeek's IP-geolocation reputation system 403s most CN-residential
clients on the very first request, leaving the default `web_search`
tool unusable for the bulk of this project's users. cn.bing.com serves
clean HTML from CN without a proxy, returns real URLs (not the
`/ck/a?u=a1<base64>` click-tracking wrappers the international `www.bing.com`
uses), and has no per-IP daily quota. Live probes across three queries
× two endpoints returned 200 with zero anti-bot markers.

- New `searchBing` + `parseBingResults` scrapes `<li class="b_algo">`
  blocks (h2 a[href] + div.b_caption p) from cn.bing.com.
- `webSearch` dispatcher's fallback is now Bing; `webSearchEngine()`
  loader returns "bing" for unset / unknown / legacy "mojeek" values.
- Legacy migration is read-only — we don't rewrite the user's
  config.json. `/search-engine mojeek` is no longer in the slash
  validation set, so users who explicitly retry the dead name see an
  immediate "invalid engine" reject instead of a silent revert loop
  on the next launch.
- Dashboard settings panel gets a Search Engine `<select>` with the
  six valid backends; POST /api/settings validates the new
  `webSearchEngine` field, persists to config, and includes it in the
  GET response + `appliesAt` hints.
- searchMojeek/parseMojeekResults/MOJEEK_ENDPOINT removed; i18n
  webErrors.mojeek* renamed to bing*; public-API surface swaps
  parseMojeekResults for parseBingResults.

Tests:
- parseBingResults: real cn.bing.com HTML fixture, empty input, h2-less
  block skip
- webSearch dispatcher hits cn.bing.com with browser UA
- webSearchEngine config loader: enum round-trip, default = bing, legacy
  "mojeek" value reads back as bing

* fix(web-search): switch parseBingResults to DOM walk to silence ReDoS scanner

CodeQL flagged three polynomial-backtracking patterns in the regex-based
parser. node-html-parser is already in the file (used by
parseSearxngHtmlResults); using its selector API eliminates the
backtracking class entirely without changing parse semantics.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-22 07:57:50 -07:00
YHHandreasonix 4e136aad4e fix(config): atomic write + restore preset-write diagnostic trace (#1555)
Users still report `preset` flipping back to its prior value after a
relaunch, even with the BOM-cascade root cause (#1431) fixed. Two
follow-on changes to close the gap:

1. `writeConfig` now writes to a sibling `${path}.${pid}.tmp` and
   `renameSync` into place. The previous `writeFileSync` is not atomic
   on Windows — a concurrent reader during the write window can see a
   truncated file, `readConfig` falls through to `{}`, and the next
   read-modify-write saves an empty baseline that loses every other
   field. The temp + rename pattern guarantees readers see either the
   old file or the new one, never an in-flight blob.

2. Restore the `REASONIX_DEBUG_PRESET` env-gated trace retired in
   #1431. Logs every `savePreset()` call, every `writeConfig()` call,
   and the dashboard PATCH bypass — each with a short stack — so the
   `tools/debug-preset-launcher.ps1` script can pinpoint who flipped
   the preset when it does. Zero cost when the env var is unset.

Co-authored-by: reasonix <[email protected]>
2026-05-22 07:16:53 -07:00
YHHandreasonix 4de6321b10 fix(client): wire timeoutMs to fetch when caller passes a signal (#1535) (#1553)
`opts.signal ?? ctrl.signal` orphaned the timer's controller whenever the
loop forwarded its `_turnAbort.signal` — i.e. every real call. A genuinely
stalled SSE body (TCP open, no `[DONE]`, no close) then sat on
`reader.read()` forever and the TUI hung in "…waiting for response…"
until the user restarted it, even though the server-side request had
already completed.

Combine both signals with `AbortSignal.any` and have the timer abort with
a reason so the loop's error path surfaces "timed out after Xms" instead
of a silent stall.

Closes #1535

Co-authored-by: reasonix <[email protected]>
2026-05-22 06:54:42 -07:00
wby 152477205f fix(tui): restrict @mention file search to current directory prefix (#1548)
For example: just in this repo
- before:
  1. input @benchmarks/compression-eval/.git
  2. press tab
  3. reasonix would show @.gitignore
  (if just input @benchmarks/compression-eval/ and press tab, then would get benchmarks/compression-eval/.gitignore correctly)
- after: same step1 and step2, in step3 reasonix would show @benchmarks/compression-eval/.gitignore
2026-05-22 06:40:27 -07:00
paradoxSCH 6ac18cfbce fix(code-system): add Task integrity guardrail preventing objective simplification (#1516)
Pure prompt-engineering tweak — addresses #1495. In long sessions the model has been rationalizing scope cuts to itself ("save tokens", "be efficient") and silently narrowing the user's task instead of asking. Adds a "Task integrity — non-negotiable" paragraph to the code-mode system prompt:

> The user's original objective and ALL constraints (especially "do NOT do X", "avoid Y", "never Z") remain in force for the entire session. You may NOT unilaterally simplify, narrow, or change the objective to save tokens, time, or steps. If you believe the objective needs adjustment, ask the user — do NOT decide on your own.

Inserted before `__ESCALATION_CONTRACT__`, after the format-rules section. About 50 tokens, in the static prefix so it caches with the rest. Additive — the existing cost-aware escalation contract is unchanged.

Refs #1495.
2026-05-22 06:31:53 -07:00
YHH fc85376c3f chore(context): capture all pinned-constraint blocks in fold tail (#1552)
Small follow-up to #1515.

`extractPinnedConstraints` used `.match()` without the `g` flag, which only returns the first capture. The system prompt can carry multiple blocks under the same prefix:

- `# User memory — global` and `# User memory — this project` both exist when a user has both (`src/memory/user.ts` emits them as separate sections).
- Multiple `# Project memory (filename)` sections can show up when a project carries per-subdir memory.

Single-match meant the second one was silently dropped from the fold-tail constraint reinforcement.

Combined the three prefixes into one regex and switched to `matchAll` so every pinned block makes it into the fold summary.
2026-05-22 06:29:49 -07:00
paradoxSCH 736deb1321 fix(context): preserve memory constraints across context folds (#1515)
Two fixes around context-fold memory preservation, plus a behavior-stability harness to regression-test them.

**`context-manager.ts`** — fold now reinforces pinned constraints after the summary:

- New `extractPinnedConstraints()` pulls the `# HIGH PRIORITY constraints`, `# User memory`, and `# Project memory` blocks out of the live system prompt and appends them verbatim under a `[PINNED CONSTRAINTS — preserved verbatim]` tail on the synthetic fold summary message. The system prompt always survives the fold (only history is compressed), but in long sessions the model's attention drifts away from instructions at the top; restating them near the current context window mitigates the "lost in the middle" effect.
- Summarizer system prompt now explicitly tells the model to preserve "do not" / "never" / "avoid" instructions when compressing — independent improvement, useful regardless of whether the new constraint tail fires.

**`loop.ts`** — wires `getSystemPrompt: () => this.prefix.system` into `ContextManagerDeps` so the fold can read the current system prompt.

**`benchmarks/behavior-stability/`** — small evals scaffold (harness / runner / report / types + one scenario). The `constraint-persistence` scenario stuffs the log with 40 synthetic turns, triggers a fold with a tiny `keepRecentTokens` budget, and asserts all four pinned constraints (2× HIGH PRIORITY, 1× User memory, 1× Project memory) survive in the folded summary. Local: `npx tsx benchmarks/behavior-stability/runner.ts --local` — 1/1 passing.

Refs #1462.
2026-05-22 06:22:09 -07:00
paradoxSCH fa5f3a01c6 fix(tools): discourage shell-based file edits in run_command description (#1514)
Pure prompt-engineering tweak. Issue #1372 reports the model reaching for `echo`/`cp`/`sed` through `run_command` to edit files, which trips the confirmation gate and ignores the dedicated `write_file` / `edit_file` / `multi_edit` / `copy_file` / `move_file` / `delete_file` tools that already have validation + rollback.

Prepends a negative-constraint paragraph to the `run_command` tool description telling the model not to use shell utilities for file operations and pointing at the right alternatives. About 50 tokens, included in the static tool spec so it caches with the rest of the prefix.

No behavior or schema change — just text the model sees every turn `run_command` is in scope.
2026-05-22 06:14:58 -07:00
TaoMu b5a2175c6e test(lifecycle): rename lifecycle harness fixtures (#1532)
Tiny follow-up to #1499. The harness helper used to register only mutation tools (`delete_file`, `move_file`, etc), but #1499 extended it to register `read_file` too so the cancel/stop-recovery tests could verify read-only operations still work. The old name `registerMutationTools` doesn't fit anymore — renaming to `registerLifecycleToolFixtures`.

Test-only, behavior-neutral.

Refs #1285. Follow-up to #1499.
2026-05-22 05:59:42 -07:00
Shaun Xiao c7368be722 feat(chat): queued mid-turn steer handling (#1501)
Lets users type into the composer while a turn is busy and have those messages queued as mid-turn steers — they're appended as user-role guidance at the next loop iteration boundary, never interrupting the in-flight request.

**Loop side (`src/loop.ts`)**

- `_steer: string | null` → `_steerQueue: string[]`. `steer(text)` pushes; `steer(null)` clears.
- At the iteration boundary, shift one off the queue, wrap with `MID_TURN_STEER_WRAPPER` ("Do not treat this as a new task; use it only as additional guidance for the current task after completing the current step.") and `appendAndPersist` it as a `user` message before the next `buildMessages()`.
- `steerConsumed` now means "queue empty after consume" — the UI gate against double-submit.
- All abort / budget / 5xx / context-guard / final-done paths clear the queue so steers can't survive past the turn that owned them.
- When a model response has no tool calls but a steer is queued, the loop now `continue`s instead of returning `done` — so the queued steer drives one more iteration.

**TUI side (`App.tsx`, `PromptInput.tsx`, `ComposerArea.tsx`)**

- New `isBusyPromptCommand(text)` rejects slash / `!`-bang / `#`-memory while busy; ordinary text is accepted as steer.
- `submitPrompt` (dashboard ctx) and the busy keystroke branch both gate on it.
- `PromptInput` separates `inputFrozen` (`disabled && !steerBusy`) from `inputActive` (`!disabled || steerBusy`); placeholder, accent color, and cursor track that. New hint row: `⏎ send — injected mid-turn · esc to stop`.

**Dashboard side (`chat.ts`, `changes.ts`)**

- Both ChatInputs drop `disabled={busy}` on textarea + submit button; placeholder switches to `chat.placeholderSteerBusy`; popover dismisses when busy.
- `loop-to-dashboard` maps `steer` events to `user` messages so the consumed steer shows up in the dashboard transcript.

**i18n** — EN + zh-CN: `placeholderSteerBusy`, `steerHint`, `steerInjected`, `steerCommandRejected`, `steerPlaceholder`, `steerHint`.

**Tests** — 3 new `tests/loop.test.ts` cases:
- steer is persisted wrapped (not raw)
- multiple steers queue + FIFO consume
- `steerConsumed` flips back to `false` on a fresh steer
2026-05-22 05:47:04 -07:00
TaoMu 6ca3c94d21 test(lifecycle): cover stop and cancel recovery (#1499)
Adds e2e coverage for two strict-lifecycle recovery paths that weren't pinned down:

1. **User cancels a proposed plan** — after `submit_plan` returns `plan cancelled: ...`, snapshot resets (`planSteps=[]`, `completedStepIds=[]`, `mutatedSinceLastStep=false`); a fresh user prompt flips state back to `armed`; read-only tools work; a follow-up mutation is still rejected with `nextAction: "submit_plan"`.
2. **Checkpoint stop** — same assertions on the post-stop fresh prompt.

To make these tests possible the harness also picks up:
- `QueueGate` now handles the `cancel` verdict on `plan_proposed` (calls `lifecycle.cancel()`).
- Registers a `read_file` tool with `readOnly: true` so the test can prove the read path still works after recovery.

Tests-only — no runtime, prompt, or tool-schema changes.

Refs #1285.
2026-05-22 05:40:35 -07:00
Bernardxu123 900c68d638 fix(tui): remove misleading ^O expand from paste sentinel label; update shortcut panel descriptions (#1517)
Drops the misleading `^O expand` action hint from the paste-sentinel chip and tightens the Ctrl+O / Ctrl+P shortcut-panel labels.

`Ctrl+O` only toggles the streaming-reply expand view; it does nothing during input, and certainly nothing for pastes. Showing `^O expand` next to a paste chip is a false promise — issue #1493's reporter pressed it and (correctly) got no response. The chip now reads `📋 pasted ... · ⌫ remove`, which is the actual interaction it offers.

While in the strings file, the shortcut descriptions are sharpened:
- `descCtrlO`: "Expand stream" → "Expand reply (streaming only)" (EN) / "展开流式输出" → "展开回复(仅流式输出期间)" (zh-CN) — clarifies the streaming-only scope.
- `descCtrlP`: "Toggle shortcut panel" → "Show/hide shortcuts" / "打开/关闭快捷键面板" → "显示/隐藏快捷键" — shorter, same meaning.

Issue #1493 also reports a separate Shift+Enter bug; that's out of scope for this PR.

Refs #1493.
2026-05-22 05:36:33 -07:00
wade1999 7eecebe3ea feat(desktop): add approval and completion notifications (#1519)
Adds OS-level notifications to the Tauri desktop app so a user away from the window finds out when:

- a new approval request appears (shell command / path access / choice / plan / checkpoint / revision)
- a long-running turn finishes (only after at least 15 s of busy → idle, to skip quick replies)

Design:

- Pure `deriveDesktopNotifications(...)` derives the list from previous-vs-current snapshots; no Tauri calls inside, so the unit tests run without a window.
- `dispatchDesktopNotifications(...)` takes injected deps (`isFocused` / `isPermissionGranted` / `requestPermission` / `sendNotification`) so the App.tsx wiring is the only place that talks to the Tauri plugin. Focus check is repeated at dispatch time as a second guard.
- Notification permission is requested at most once per session; a denial sticks.
- All copy goes through `t()` against the active desktop UI language; both EN and zh-CN added.

Wiring lives in `TabRuntime` via a `useEffect` over the pending-state slices + busy flag.

`tauri-plugin-notification` is added on both the JS and Rust sides (`Cargo.toml` + `capabilities/default.json`). A vitest alias maps the plugin to a mock so the desktop test suite stays hermetic.
2026-05-22 05:32:19 -07:00
YHH b43dd4bb91 chore(changes): gate @mention popover on MODE === "attached" (#1549)
Follow-up to #1546: gate the new `@mention` popover behind `MODE === "attached"`, matching the existing pattern in `chat.ts`.

Without this guard, standalone dashboard clients call `/files` on every `@` keystroke and get a 503 (`"@-mention picker requires a code-mode session"`) back — the popover never opens but each `@` still costs a round-trip. The `MODE` import added in #1546 becomes load-bearing here.
2026-05-22 05:08:48 -07:00
ForeverYoungPp ca68ba07f9 fix(changes): add @mention file picker popover to ChatPane (#1546)
Brings the `@`-mention file picker to the dashboard's Changes / ChatPane, mirroring what's already in `chat.ts`:

- `PopoverKind` extended with `"mention"`.
- `updatePopover` detects the trailing `@prefix` token and posts to `/files` for matching paths.
- `applyPopover` replaces the `@prefix` token in place (not the whole input) so the user keeps anything they typed before it.
- Popover header / row icon switch between `/` and `@` based on `popoverKind`.

The `chat.projectFiles` i18n key already exists in EN + zh-CN — no new strings.
2026-05-22 04:40:26 -07:00
Tianjiu f0f1ac60a5 fix(web): block SSRF in web_fetch (#1544)
Closes a real SSRF hole in `web_fetch`. Previously the tool accepted any `http(s)` URL and auto-followed redirects, so the model could:

- hit `127.0.0.1` / `::1` directly,
- reach private / link-local / metadata ranges,
- or be redirected from a public host into an internal one mid-chain.

Defense-in-depth in `src/tools/web.ts`:

- Scheme allowlist (`http:` / `https:` only).
- IP-literal check rejecting RFC 1918 + loopback + link-local + CGN (100.64/10) + TEST-NET-1/2/3 + multicast + reserved + benchmarking (198.18/15).
- IPv6 check covering `::`, `::1`, ULA (`fc00::/7`), link-local (`fe80::/10`), multicast (`ff00::/8`), and `::ffff:` IPv4-mapped (recursing into the v4 check).
- For hostnames, `dns.lookup(host, { all: true })` and reject if **any** resolved address is internal — defends against half-public/half-private multi-A records.
- Manual redirect handling with re-validation on every hop + a 5-redirect cap.
- Timeout / response-size limits untouched.

Tests cover loopback literal, hostname resolving to an internal IP, and a public→internal redirect.
2026-05-22 04:35:12 -07:00
Evan-Pycraft 72f2478f35 feat(i18n): localize CLI command output strings (MCP, sessions, prune, theme) (#1524)
Localizes the remaining hardcoded CLI output strings — `reasonix mcp list / search / install` flow, `sessions` listing, `prune-sessions`, and the `/theme` slash handler — so zh-CN users see translated text instead of mixed EN/CN output.

31 new keys across `sessions` / `mcpCli` / `app` namespaces with full EN ↔ zh-CN parity. Mechanical migration — `t()` calls replace each string with the same parameters; no behavior change.

Verified clean rebase on top of the stage-1-through-4 refactor (#1530 / #1534 / #1536 / #1537) plus the path-approval bridge (#1540): typecheck + lint + 3528 tests all pass.
2026-05-22 02:34:42 -07:00
YHH 57a3624d13 fix(web): bridge path-access approval modal to the dashboard (#1538)
When the model asked for file access outside the sandbox, the TUI opened `PathConfirm` but the web dashboard stayed blank — the loop hung waiting for a decision the web user couldn't make. Same shape as #832 (plan-checkpoint). This PR bridges the path-approval gate end-to-end.

- `ActiveModal` gains a `path` variant (path / intent / toolName / sandboxRoot / allowPrefix).
- `DashboardContext.resolvePathConfirm` mirrors `resolveShellConfirm`.
- `App.tsx` broadcasts `modal-up` / `modal-down` for `pendingPath`, surfaces it in `getActiveModal` (mid-modal reconnect repaints correctly), wires `resolvePathConfirm` to `handlePathConfirmRef`.
- `/api/modal/resolve` accepts `kind: "path"` with `run_once` / `always_allow` / `deny`.
- New `PathModal` Preact component renders with sandbox / allow-prefix rows and three resolution buttons. EN + zh-CN strings added.

Audited every loop-blocking gate while in there — after this lands all of run_command / path_access / plan / plan_checkpoint / plan_revision / choice / edit_review have a TUI ↔ web bridge. Slash-command pickers (ModelPicker / ThemePicker / EditPicker / WorkspacePicker) are still TUI-only but don't block the loop, so out of scope here.

Closes #1538.
2026-05-22 02:28:40 -07:00
YHH cd8def12b6 refactor(tui): drop the byte-virtual frame layer (stage 4 of #1529)
Final stage of #1529. The Frame algebra (`vstack`, `hstack`, `slice`, `overlay`, `viewport`, `bottom`, `fitWidth`, etc.) and its ANSI rasterizer existed so a full-screen persistent frame could be composed cell-by-cell. With Static-anchored history and Ink-native rendering, none of that is exercised — `frame-render.tsx` was the only caller of the Frame layer, and it itself had zero callers.

**Deleted:** `src/frame/{frame,ansi,types,index}.ts`, `src/cli/ui/frame-render.tsx`, `tests/frame.test.ts`.

**Migrated:** `src/frame/width.ts` → `src/cli/ui/text-width.ts`. These are text-cell utilities (`stringWidth` passthrough to the `string-width` lib, grapheme-safe `clipToCells` / `wrapToCells` / `graphemes` / `graphemeWidth`) that card renderers and markdown still need — they're not "frame" anything. Implementation is unchanged. 6 src callers and 2 tests updated. New `tests/text-width.test.ts` rescues the width-relevant edge-case coverage that lived in the deleted `frame.test.ts` (CJK, Hiragana / Katakana / Hangul, emoji, ZWJ clusters, combining marks, control chars, mixed scripts) and adds cases for `clipToCells` / `wrapToCells`.

Net: 907 deletions, 9 insertions across 16 files (plus the new 60-line `text-width.ts` and 100-line test file as untracked-then-added).

Closes #1529. Cumulative across stages 1–4: ≈ -2620 lines.
2026-05-22 02:10:48 -07:00
YHH b465b73178 refactor(tui): drop viewport-budget row allocator (stage 3 of #1529)
Stage 3 of #1529. The vertical row allocator existed so a full-screen persistent frame could parcel rows between zones (modal, status, input, stream, safety). With Static-anchored history the persistent frame sizes to content and the zones don't compete; there's nothing left to allocate.

**Deleted:** `src/cli/ui/layout/viewport-budget.tsx` (reducer + allocator + `ViewportBudgetProvider` + `useReserveRows` + `useTotalRows`) and its tests.

**Trimmed:** `App.tsx` drops the `<ViewportBudgetProvider>` wrapper (and the indentation it imposed). `StreamingCard`, `PromptInput`, `ChoiceConfirm`, `PathConfirm`, `ShellConfirm`, `PlanCheckpointConfirm` were all side-effect-only callers — removed. `EditConfirm` and `PlanConfirm` were the only consumers that read the allocator's return value; both swap `useTotalRows()` for `useStdout().stdout?.rows` and inline the max into a direct expression. `tests/plan-confirm.test.tsx` renders without the provider wrapper now.

**Kept:** `src/cli/ui/prompt-viewport.ts` — composer-internal input-cell math (CJK width, paste sentinels, single-row slicing). The stage 3 plan said "drop viewport-budget / prompt-viewport, after confirming the composer no longer depends on them"; the composer still depends on prompt-viewport, so it stays.

Net: 727 deletions, 433 insertions across 12 files (most of the App.tsx insertion count is biome re-indenting the subtree that lost its wrapper).
2026-05-22 01:50:23 -07:00
YHH 8a8d7655e6 refactor(tui): make Static the only history renderer; drop virtual-viewport machinery (stage 2 of #1529)
Stage 2 of #1529. `StaticCardStream` landed behind a flag in Stage 1 and held up under real use; this stage promotes it to the only path and deletes the machinery whose entire job was fighting the terminal for scrollback.

**Deleted:** `CardStream` (virtual viewport + height measurement + monotonic-shrink lock + scroll indicator), `chat-scroll-store` + `chat-scroll-provider`, `copy-mode/` (vim/tmux selection mode + OSC 52 snapshot), `InputAreaWithHistoryHint` (unused dead leaf), 5 obsolete tests, the `/copy` slash command + `openCopyMode` slash result + the qq-channel terminal-only notice + the `copyMode` i18n block.

**Trimmed:** `App.tsx` -114 net (drops `STATIC_HISTORY` flag, `HistoryTypingCapture`, `ChatScrollProvider` wrapper, `useChatScroll*` subscriptions, `pendingCopyMode`, the PgUp/PgDn/End/wheel/arrow keystroke handler, the conditional outer Box height); `ComposerArea` no longer wraps the input area in the "scrolled-up history hint" branch; `PromptInput` drops the absolute IME cursor write (its math required the persistent UI at terminal bottom and the Static frame floats); `mouse-mode` defaults to `off` everywhere with the env-var override kept as an escape hatch.

Net: 1545 deletions, 48 insertions across 26 files.
2026-05-22 01:36:21 -07:00
YHH bb76851f95 feat(tui): static-history path behind REASONIX_STATIC_HISTORY (stage 1 of #1529)
Stage 1 of #1529. Adds a Static-based history renderer behind `REASONIX_STATIC_HISTORY=1` so we can validate it in parallel before later stages delete the virtual-viewport CardStream + chat-scroll-store + copy-mode entirely.

- New `StaticCardStream` partitions cards at the first unsettled entry: settled prefix goes through Ink `<Static>` (terminal owns scrollback), live tail renders dynamically.
- Outer Box drops `height={stdout.rows}` under the flag so the persistent frame doesn't push Static commits straight into off-screen scrollback.
- Mouse mode resolves to a known wheel-passthrough state under the flag, and emits disable sequences for `?1000 / ?1002 / ?1003 / ?1006 / ?1007 / ?1015` on every "off" enable so stale capture from a crashed prior session can't keep eating wheel events.
- `PromptInput`'s absolute IME cursor positioning is skipped under the flag — its `\x1b[row;colH` math assumed the persistent UI sits at the terminal bottom; with the floating Static frame the absolute write desynced Ink's cursor tracking and ghosted previous frames.

Off by default; nothing changes for users who don't opt in.
2026-05-22 01:12:45 -07:00
Bernardxu123 78e7327ead fix(tui): Ctrl+Enter inserts newline and update shortcut panel (#1513)
Ctrl+Enter now inserts a newline instead of submitting, matching the behavior of Shift+Enter and Alt+Enter.

Changes:
- multiline-keys.ts: add key.ctrl to newline condition
- PromptInput.tsx: HintRow shows ^J as alt newline shortcut
- ShortcutsHelpModal.tsx: add Ctrl+Enter and Ctrl+J entries
- i18n: add descCtrlEnter / descCtrlJ translations

Refs #1493
2026-05-21 23:37:26 -07:00
YHHandreasonix c9f8bc1507 fix(client): translate DeepSeek 429 into a concurrency-limit hint (#1526)
DeepSeek's actual rate limit is concurrency-based, not RPM (500 for
v4-pro, 2500 for v4-flash, account-wide across API keys). When the
account exceeds it the server returns HTTP 429, which currently
surfaces as a raw `DeepSeek 429: {...body}` string — fine for a
maintainer to read, useless for a user trying to figure out why.

Add a friendly message that names the actual cap, identifies the
likely cause (another reasonix process on the same key, or a
parallel subagent fan-out that overshot), and points at the
remediation (wait + retry, reduce parallelism, or request expansion).
Same shape as the existing 401/402/422/400 cases.

Also clarify the `rateLimit.rpm` config docstring: it's a client-side
self-throttle for politeness on shared infra, not a DeepSeek-enforced
limit. DeepSeek has never had a published RPM cap.

Closes #1522

Co-authored-by: reasonix <[email protected]>
2026-05-21 23:30:32 -07:00
YHHandreasonix 1024a0cc44 fix(edit): preserve file encoding on read/edit/restore (GB18030, UTF-8 BOM) (#1518)
* fix(edit): preserve file encoding on read/edit/restore (GB18030, UTF-8 BOM)

read_file / edit_file / multi_edit / applyEditBlock all hard-coded
UTF-8 in readFileSync + writeFileSync. On Chinese Windows hosts most
project files are GB18030; lossy UTF-8 decode mangled the bytes so
the model's SEARCH text never byte-matched, edit_file silently
failed, and the model fell back to PowerShell + git-replay (token-
expensive and lossy for any user manual edits in between).

The shell stdout path already handled this via smartDecodeOutput's
UTF-8-fatal → GB18030 fallback. Same pattern, applied to the
filesystem path, plus encode-back via iconv-lite so the file stays
in its original encoding. UTF-8 BOM round-trips too. Snapshot
records prevEncoding so restoreSnapshots writes back correctly.

Closes #1445

* fix(edit): make GB18030 fallback platform-independent

CI ubuntu build flagged the previous platform gate — GB18030 files
are portable, Linux developers can have CN-Windows-origin files in
their projects. The gate was a copy of smartDecodeOutput's
cmd.exe-specific behavior that doesn't apply to file-on-disk
encoding detection.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-21 22:36:06 -07:00
YHHandreasonix 002a97fe91 feat(tui): add mouseWheelRows config knob for per-terminal scroll tuning (#1511)
Mouse wheel sensitivity is terminal-dependent — most emit 2-5 SGR
mouse reports per physical notch (#1419 settled SCROLL_WHEEL_ROWS=1
to keep that case bearable), but some emit one report per notch and
the resulting 1-row-per-notch scroll feels broken (#1494).

A constant-bump fix would regress the over-sensitive class. Add
`mouseWheelRows?: number` (clamped to [1,10]) so affected users can
tune without compromising the default that #1419 validated.

Closes #1494

Co-authored-by: reasonix <[email protected]>
2026-05-21 20:59:14 -07:00
YHHandreasonix 45f9a65fd4 fix(proxy): allow opting out of the DeepSeek direct-route default (#1507)
`DEFAULT_NO_PROXY` hardcoded `api.deepseek.com` / `*.deepseek.com` so
the dispatcher always routed them through `undici.Agent()` direct,
bypassing HTTPS_PROXY. Corporate firewalls that block direct egress
therefore broke `reasonix doctor`'s api-reach check with a
ConnectTimeoutError, with no way for the user to override.

Split the defaults: loopback stays mandatory, DeepSeek becomes opt-out
via `proxy.bypassDeepSeekDirect: false` (config) or
`REASONIX_PROXY_DEEPSEEK_DIRECT=0` (env). Default behaviour preserves
the clash/v2ray US-exit-IP 403 fix that put the bypass there.

Closes #1497

Co-authored-by: reasonix <[email protected]>
2026-05-21 20:14:46 -07:00
TaoMu 3cc72075f2 feat(plans): surface lifecycle evidence summaries (#1500) 2026-05-21 19:38:30 -07:00
TaoMu d807c1f9b8 test(lifecycle): cover failed verification revise flow (#1498) 2026-05-21 19:27:10 -07:00
YoungDan-hero acb1dca762 fix: constrain shell redirects to sandbox boundaries (#1457)
Validate redirect targets before execution to block sandbox escapes, symlink traversal, and sensitive redirect paths. Preserve PauseGate test stub typing and support case-insensitive Windows environment variables in executable resolution.
2026-05-21 19:11:08 -07:00
YoungDan-hero e5bb6b4a24 fix: prevent edit snapshots from reading outside workspace (#1454)
* fix: resolve root-relative edit block paths

* fix: prevent edit snapshots from reading outside workspace
2026-05-21 18:59:52 -07:00
Irene Luoandllr-selfgit 4e8a49408e feat(tools): limit tool dispatch rate (#1356)
Co-authored-by: llr-selfgit <[email protected]>
2026-05-21 18:45:35 -07:00
reasonix 245e13b245 fix(desktop): tsc errors blocking desktop bundle build
- App.tsx $session_empty branch was missing `id` on the error card so
  TS rejected the literal against ChatMessage's required-id variant.
- App.test.ts stub state still used the pre-#1487 UsageStats shape
  (`tokens.{input,output,...}`); update to the current flat shape so
  the test compiles.

Caught by the desktop-v* release workflow — root verify doesn't run
desktop tsc, so these regressed silently after #1487.
2026-05-21 06:36:05 -07:00
reasonix be414940cb fix(desktop): drop workspace:* protocol for core-utils — desktop isn't a workspace member
`npm ci` inside desktop/ failed with EUNSUPPORTEDPROTOCOL because desktop/
is not listed under root workspaces, so the `workspace:*` spec added in
#1443 had no resolver. Replace with `file:../packages/core-utils`,
which is what the desktop install actually wants (source-only package,
type-only import in desktop sources).
2026-05-21 06:25:41 -07:00
reasonix b6492b2677 chore(dsnix): bump to 0.48.1 + sync lockfile 2026-05-21 06:20:55 -07:00
144 changed files with 5949 additions and 3623 deletions

No files matched your search

+110
View File
@@ -3,6 +3,116 @@
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.49.0] — 2026-05-22
**TUI — Static-history renderer.** The history pane is now rendered
exclusively through Ink's `<Static>` (#1529, stages 1–4). The previous
virtual-viewport machinery — viewport-budget row allocator, byte-virtual
frame layer, manual scroll math — is gone. Net: less code on the hot
path, no more "frame drift" on very long sessions, and the rewrite stays
Ink-native instead of fighting it. Behind `REASONIX_STATIC_HISTORY` at
stage 1, default-on by stage 2, scaffolding removed by stage 4.
**Chat — queued mid-turn steers.** Typing while the model is mid-turn
no longer fights the live render or drops the keystroke. New input
queues, surfaces a small "queued" hint, and feeds into the next user
turn cleanly (#1501). Pairs with the static-history switch so the
queued line stays visible across re-renders.
**Web search — Bing default, dashboard engine switcher.** Default web
backend switches from Mojeek to Bing (#1558); the dashboard gains a
visible engine switcher so you can flip between configured backends
without editing config. Mojeek is dropped from the bundled set entirely
in this release.
**Plans — lifecycle evidence summaries.** Plan acceptance now surfaces
the evidence captured at each lifecycle step (#1500), so the "why this
plan is ready to accept" question has a structured answer instead of
implicit trust. Builds on the strict-lifecycle observability that
landed in 0.48.1.
**Desktop — approval + completion notifications.** Native OS
notifications for approval prompts and turn-complete events (#1519),
so a long-running turn can land in the background and still surface.
Uses `tauri-plugin-notification`.
**i18n — CLI command output.** `/mcp`, `/sessions`, `/prune`, `/theme`
output is now translated (#1524), closing the remaining English-only
gap in CLI command surfaces. Approval-prompt confirm titles and action
labels also get zh-CN (#1560/#1561).
**Security.**
- `web_fetch` now blocks SSRF — private/loopback/link-local targets are
rejected at the boundary (#1544)
- Edit snapshots can no longer read outside the workspace (#1454)
- Shell redirects (`>`, `>>`, `<`) are constrained to the sandbox
boundary (#1457)
- New Task integrity guardrail catches objective simplification — i.e.
the model trying to redefine a hard requirement away (#1516)
**Tools.** `run_command` description now actively discourages
shell-based file edits in favor of the structured `edit` tool (#1514).
A per-turn dispatch-rate limit lands for tool calls (#1356), keeping
runaway loops from burning a quota slice in one turn.
**TUI polish.**
- `Ctrl+Enter` now inserts a newline (#1513) — matching how every
modern composer treats the modifier
- `mouseWheelRows` config knob tunes scroll velocity per terminal
(#1511) so trackpad vs. wheel mouse don't fight the default
- `@mention` file search is restricted to the current directory prefix
(#1548) so the picker doesn't dump the whole repo
- Paste-sentinel label no longer claims `^O` expands the paste — the
shortcut never existed (#1517)
**Context.** Pinned-constraint blocks are now preserved across context
folds (#1515) and the fold tail captures all of them, not just the last
(#1552). Memory constraints survive the fold instead of silently
dropping out under pressure.
**Client.**
- DeepSeek 429s are translated into an actionable "concurrency limit
hit" hint instead of a raw HTTP error (#1526)
- `timeoutMs` is now wired to `fetch` when the caller passes an
`AbortSignal` (#1535) — previously the timeout was silently ignored
- `--no-proxy` / `REASONIX_NO_PROXY` opt-out is honored for the
DeepSeek direct-route default (#1507)
**Files.** Read/edit/restore now preserve the source file's encoding
(GB18030, UTF-8 BOM, etc.) instead of normalizing to UTF-8 on write
(#1518). Important for CJK Windows projects.
**Web/Dashboard.**
- Path-access approval modal is bridged to the dashboard (#1538) so
the browser surface can resolve the same prompts as CLI/Desktop
- `@mention` file picker popover lands in the dashboard ChatPane
(#1546), gated on attached mode (#1549)
**Config.** Atomic config writes restore the preset-write diagnostic
trace (#1555), so when a preset write goes wrong you can actually see
why.
**Refactor.** `lifecycle` extracts a standalone risk policy module
(#1557), shrinking the orchestrator and unblocking risk policy reuse
from non-lifecycle callers.
**Edit safety.** `read-before-edit` is now enforced at the dispatch
gate, not as a per-tool check (#1563) — `edit` / `multi_edit` refuse
to fire on a file the model hasn't read this session, closing the
race where a SEARCH block could match stale bytes after an external
write.
**Cache.** Fold-summary requests now reuse the live agent's system
prompt + tool list as their prefix, so the cached bytes the main
agent just paid for carry over to the summary call (#1565). Measured
on a 48.7K-token fold: 0% → 99.6% cache hit, ~89% input cost
reduction per fold.
**Other:**
- `fix(desktop)` drop `workspace:*` protocol for core-utils — desktop
isn't a workspace member
- `fix(desktop)` tsc errors blocking desktop bundle build
## [0.48.1] — 2026-05-21
**`dsnix` — short alias.** A new `dsnix` shim package lands alongside
+39
View File
@@ -0,0 +1,39 @@
/** Evaluation harness — runs scenarios and collects results. */
import type { EvalReport, EvalResult, EvalScenario } from "./types.js";
export async function runScenarios(
scenarios: EvalScenario[],
opts: {
onResult?: (r: EvalResult) => void;
} = {},
): Promise<EvalReport> {
const results: EvalResult[] = [];
for (const sc of scenarios) {
const t0 = Date.now();
let result: EvalResult;
try {
result = await sc.run();
result.durationMs = Date.now() - t0;
} catch (err) {
result = {
scenarioId: sc.id,
pass: false,
durationMs: Date.now() - t0,
details: `UNEXPECTED ERROR: ${(err as Error).message}`,
};
}
results.push(result);
opts.onResult?.(result);
}
// Dynamic import avoids bundling VERSION into the harness module.
const { VERSION } = await import("../../src/version.js");
return {
meta: {
date: new Date().toISOString(),
reasonixVersion: VERSION,
},
results,
};
}
+81
View File
@@ -0,0 +1,81 @@
/** Render behavior-stability results.json → markdown report. */
import { readFileSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import type { EvalReport } from "./types.js";
interface CliArgs {
input: string;
outPath: string;
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = { input: "", outPath: "benchmarks/behavior-stability/report.md" };
const positional: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--out") out.outPath = argv[++i] ?? out.outPath;
else if (a && !a.startsWith("--")) positional.push(a);
}
out.input = positional[0] ?? "";
if (!out.input) {
throw new Error(
"usage: npx tsx benchmarks/behavior-stability/report.ts <results.json> [--out report.md]",
);
}
return out;
}
function renderHeader(report: EvalReport): string {
return `# Reasonix Behavior-Stability Evaluation
**Date:** ${report.meta.date}
**Version:** ${report.meta.reasonixVersion}
`;
}
function renderSummary(report: EvalReport): string {
const passed = report.results.filter((r) => r.pass).length;
const total = report.results.length;
const pct = total > 0 ? ((passed / total) * 100).toFixed(0) : "0";
const rows = report.results.map((r) => {
const icon = r.pass ? "✅" : "❌";
return `| ${icon} | \`${r.scenarioId}\` | ${r.durationMs}ms | ${r.details} |`;
});
return `## Summary
**${passed}/${total} passed (${pct}%)**
| status | scenario | time | details |
|---|---:|---:|:---|
${rows.join("\n")}
`;
}
function render(report: EvalReport): string {
return [renderHeader(report), renderSummary(report)].join("\n\n");
}
function isMain(): boolean {
const entry = process.argv[1];
if (!entry) return false;
return import.meta.url === pathToFileURL(entry).href;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const raw = readFileSync(args.input, "utf8");
const report = JSON.parse(raw) as EvalReport;
const md = render(report);
writeFileSync(args.outPath, md, "utf8");
console.log(`Wrote ${args.outPath} (${report.results.length} results)`);
}
if (isMain()) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
+87
View File
@@ -0,0 +1,87 @@
/** CLI runner for behavior-stability evaluation. */
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { loadDotenv } from "../../src/index.js";
import { ALL_SCENARIOS, API_SCENARIOS, LOCAL_SCENARIOS } from "./scenarios.js";
import { runScenarios } from "./harness.js";
import type { EvalScenario } from "./types.js";
loadDotenv();
interface CliArgs {
local: boolean;
api: boolean;
outPath: string | null;
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = { local: false, api: false, outPath: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--local") out.local = true;
else if (a === "--api") out.api = true;
else if (a === "--out") out.outPath = argv[++i] ?? null;
}
// Default to local-only when neither flag is provided.
if (!out.local && !out.api) out.local = true;
return out;
}
function pickScenarios(args: CliArgs): EvalScenario[] {
const scenarios: EvalScenario[] = [];
if (args.local) scenarios.push(...LOCAL_SCENARIOS);
if (args.api) scenarios.push(...API_SCENARIOS);
return scenarios;
}
function isMain(): boolean {
const entry = process.argv[1];
if (!entry) return false;
return import.meta.url === pathToFileURL(entry).href;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const scenarios = pickScenarios(args);
if (scenarios.length === 0) {
console.log("No scenarios selected. Use --local, --api, or both.");
process.exit(0);
}
const categories = new Set(scenarios.map((s) => s.category));
console.log(`Running ${scenarios.length} scenario(s) [${[...categories].join(", ")}] …\n`);
const report = await runScenarios(scenarios, {
onResult: (r) => {
const icon = r.pass ? "✅" : "❌";
console.log(`${icon} ${r.scenarioId} (${r.durationMs}ms)`);
console.log(` ${r.details}\n`);
},
});
const passed = report.results.filter((r) => r.pass).length;
const total = report.results.length;
console.log(`────────────────────────────────────────`);
console.log(`Results: ${passed}/${total} passed`);
console.log(`Version: ${report.meta.reasonixVersion}`);
console.log(`Date: ${report.meta.date}`);
if (args.outPath) {
const dir = dirname(args.outPath);
if (dir !== ".") mkdirSync(dir, { recursive: true });
writeFileSync(args.outPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(`Wrote ${args.outPath}`);
}
if (passed < total) process.exit(1);
}
if (isMain()) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
+147
View File
@@ -0,0 +1,147 @@
/** Behavior-stability scenario for PR #1462 — constraint persistence across context folds. */
import { AppendOnlyLog, SessionStats } from "../../src/index.js";
import { ContextManager } from "../../src/context-manager.js";
import { Usage } from "../../src/client.js";
import type { EvalResult, EvalScenario } from "./types.js";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function withTiming(fn: () => Promise<EvalResult>): Promise<EvalResult> {
const t0 = Date.now();
return fn().then((r) => ({ ...r, durationMs: Date.now() - t0 }));
}
function pass(id: string, details: string): EvalResult {
return { scenarioId: id, pass: true, durationMs: 0, details };
}
function fail(id: string, details: string): EvalResult {
return { scenarioId: id, pass: false, durationMs: 0, details };
}
/* ------------------------------------------------------------------ */
/* Mock client — returns a fixed summary for fold testing */
/* ------------------------------------------------------------------ */
class MockDeepSeekClient {
private _replyContent: string;
constructor(replyContent: string) {
this._replyContent = replyContent;
}
async chat(): Promise<{
content: string;
reasoningContent: string;
usage: Usage;
}> {
return {
content: this._replyContent,
reasoningContent: "",
usage: new Usage(100, 50, 150, 0, 100),
};
}
}
/* ------------------------------------------------------------------ */
/* #1462 — Constraint persistence across folds (local) */
/* ------------------------------------------------------------------ */
const constraintPersistence: EvalScenario = {
id: "constraint-persistence",
name: "Context fold preserves pinned constraints from system prompt",
category: "context",
requiresApi: false,
run: () =>
withTiming(async () => {
const systemPrompt = `You are a coding assistant.
# HIGH PRIORITY constraints
- DO NOT use npm install without asking.
- Never delete files without confirmation.
# User memory
The user prefers TypeScript over JavaScript.
# Project memory
This project uses pnpm instead of npm.`;
const log = new AppendOnlyLog();
// Fill log with synthetic long history to trigger a fold boundary > 0.
for (let i = 0; i < 40; i++) {
log.append({
role: "user",
content: `Turn ${i}: Please review the codebase and tell me what you found.`,
});
log.append({
role: "assistant",
content: `Turn ${i} summary: I reviewed the codebase and found several issues including unused imports, missing type annotations, and inconsistent formatting.`,
});
}
const client = new MockDeepSeekClient("Summary of all prior turns.");
const stats = new SessionStats();
const ctx = new ContextManager({
client: client as any,
log,
stats,
sessionName: null,
getAbortSignal: () => new AbortController().signal,
getCurrentTurn: () => 1,
getSystemPrompt: () => systemPrompt,
});
// Use a tiny tail budget so almost everything becomes head and gets summarized.
const result = await ctx.fold("deepseek-v4-flash", { keepRecentTokens: 200 });
if (!result.folded) {
return fail(
"constraint-persistence",
`Fold did not happen (before=${result.beforeMessages}, after=${result.afterMessages}).`,
);
}
const foldedSystem = log.toMessages()[0];
if (foldedSystem?.role !== "assistant") {
return fail(
"constraint-persistence",
`Expected first folded message to be assistant summary, got ${foldedSystem?.role}.`,
);
}
const content = foldedSystem.content as string;
const checks = [
{ text: "DO NOT use npm install without asking", label: "HIGH PRIORITY constraint" },
{ text: "Never delete files without confirmation", label: "HIGH PRIORITY constraint 2" },
{ text: "TypeScript over JavaScript", label: "User memory" },
{ text: "pnpm instead of npm", label: "Project memory" },
];
const missing = checks.filter((c) => !content.includes(c.text));
if (missing.length > 0) {
return fail(
"constraint-persistence",
`Folded summary missing ${missing.length} constraint(s): ${missing.map((m) => m.label).join(", ")}.`,
);
}
return pass(
"constraint-persistence",
`Fold reduced ${result.beforeMessages} → ${result.afterMessages} messages and preserved all ${checks.length} pinned constraints.`,
);
}),
};
/* ------------------------------------------------------------------ */
/* Exports */
/* ------------------------------------------------------------------ */
export const LOCAL_SCENARIOS: EvalScenario[] = [constraintPersistence];
export const API_SCENARIOS: EvalScenario[] = [];
export const ALL_SCENARIOS: EvalScenario[] = [...LOCAL_SCENARIOS, ...API_SCENARIOS];
+26
View File
@@ -0,0 +1,26 @@
/** Behavior-stability evaluation types — lightweight, local-first regression suite. */
export interface EvalScenario {
id: string;
name: string;
category: "shell" | "context" | "integrity";
/** True when the scenario calls the live LLM API (costs money). */
requiresApi: boolean;
run: () => Promise<EvalResult>;
}
export interface EvalResult {
scenarioId: string;
pass: boolean;
durationMs: number;
details: string;
}
export interface EvalReport {
meta: {
date: string;
reasonixVersion: string;
model?: string;
};
results: EvalResult[];
}
@@ -57,6 +57,14 @@ interface ShellModalSpec {
shellKind?: string;
}
interface PathModalSpec {
path: string;
intent: "read" | "write";
toolName: string;
sandboxRoot: string;
allowPrefix: string;
}
interface ChoiceOption {
id: string;
title: string;
@@ -372,6 +380,34 @@ export function ShellModal({ modal, onResolve }: { modal: ShellModalSpec; onReso
`;
}
export function PathModal({ modal, onResolve }: { modal: PathModalSpec; onResolve: OnResolve }) {
useLang();
const intentLabel = modal.intent === "write" ? t("modal.pathWrite") : t("modal.pathRead");
return html`
<${ModalCard}
accent="#fbbf24"
icon="📂"
title=${t("modal.pathTitle")}
subtitle=${t("modal.pathSubtitle", { intent: intentLabel, tool: modal.toolName })}
>
<div class="modal-cmd"><code>${modal.path}</code></div>
<div class="modal-meta">
<div><span class="modal-meta-label">${t("modal.pathSandboxLabel")}</span> <code>${modal.sandboxRoot}</code></div>
${
modal.allowPrefix && modal.allowPrefix !== modal.path
? html`<div><span class="modal-meta-label">${t("modal.pathAllowPrefixLabel")}</span> <code>${modal.allowPrefix}</code></div>`
: null
}
</div>
<div class="modal-actions">
<button class="primary" onClick=${() => onResolve("path", "run_once")}>${t("modal.runOnce")}</button>
<button onClick=${() => onResolve("path", "always_allow")}>${t("modal.alwaysAllow", { prefix: modal.allowPrefix ?? modal.path })}</button>
<button class="danger" onClick=${() => onResolve("path", "deny")}>${t("modal.deny")}</button>
</div>
<//>
`;
}
export function ChoiceModal({ modal, onResolve }: { modal: ChoiceModalSpec; onResolve: OnResolve }) {
useLang();
const [custom, setCustom] = useState("");
+16
View File
@@ -118,6 +118,14 @@ export const en = {
effortHigh: "high (cheaper / faster)",
webSearch: "web search",
webSearchNote: "web_fetch + web_search tools",
webSearchEngine: "search engine",
webSearchEngineBing: "bing — default, works from CN without proxy",
webSearchEngineSearxng: "searxng — self-hosted (set endpoint via /se searxng <url>)",
webSearchEngineMetaso: "metaso — 100/day free (CN-friendly, free key at metaso.cn)",
webSearchEngineTavily: "tavily — 1000/mo free (set TAVILY_API_KEY)",
webSearchEnginePerplexity: "perplexity — AI-native (set PERPLEXITY_API_KEY)",
webSearchEngineExa: "exa — AI-native 1000/mo free (set EXA_API_KEY)",
webSearchEngineNote: "applies to the next web_search call",
sectionCompute: "Compute",
proNext: "/pro one-shot",
proArm: "Arm for next turn",
@@ -164,6 +172,8 @@ export const en = {
modeView: "session view",
placeholder: "Type a prompt — Enter sends, Shift+Enter for a newline · / @ for pickers",
placeholderBusy: "wait for the current turn to finish…",
placeholderSteerBusy: "type to steer — commands and pickers are disabled while busy",
steerHint: "message steers the current turn (added as instruction)",
send: "Send",
new: "New",
clear: "Clear",
@@ -694,6 +704,12 @@ export const en = {
shellBgTitle: "background process",
shellSubtitle: "model wants to run a shell command",
shellBgSubtitle: "long-running — keeps running after approval",
pathTitle: "file outside sandbox",
pathSubtitle: "{intent} via {tool}",
pathRead: "read",
pathWrite: "write",
pathSandboxLabel: "sandbox",
pathAllowPrefixLabel: "allow prefix",
runOnce: "Run once",
alwaysAllow: 'Always allow "{prefix}"',
deny: "Deny",
+16
View File
@@ -118,6 +118,14 @@ export const zhCN = {
effortHigh: "high(更便宜 / 更快)",
webSearch: "网页搜索",
webSearchNote: "web_fetch + web_search 工具",
webSearchEngine: "搜索引擎",
webSearchEngineBing: "bing — 默认,国内裸 IP 直连,无需代理",
webSearchEngineSearxng: "searxng — 自托管(端点用 /se searxng <url> 配置)",
webSearchEngineMetaso: "metaso — 每日 100 次免费(国内友好,metaso.cn 可领免费 key)",
webSearchEngineTavily: "tavily — 每月 1000 次免费(设置 TAVILY_API_KEY)",
webSearchEnginePerplexity: "perplexity — AI 直接回答(设置 PERPLEXITY_API_KEY)",
webSearchEngineExa: "exa — AI 直接回答,每月 1000 次免费(设置 EXA_API_KEY)",
webSearchEngineNote: "下一次 web_search 调用生效",
sectionCompute: "计算",
proNext: "/pro 单轮",
proArm: "为下一轮装备",
@@ -164,6 +172,8 @@ export const zhCN = {
modeView: "会话视图",
placeholder: "输入提示词 — Enter 发送,Shift+Enter 换行 · / @ 打开选择器",
placeholderBusy: "请等待当前轮次完成…",
placeholderSteerBusy: "输入消息以引导当前任务 — 忙碌时不支持命令和选择器",
steerHint: "消息将作为附加指示引导当前轮次",
send: "发送",
new: "新建",
clear: "清除",
@@ -667,6 +677,12 @@ export const zhCN = {
shellBgTitle: "后台进程",
shellSubtitle: "模型想要运行一条 Shell 命令",
shellBgSubtitle: "长时间运行 — 批准后继续运行",
pathTitle: "沙箱外文件访问",
pathSubtitle: "{intent} · 通过 {tool}",
pathRead: "读取",
pathWrite: "写入",
pathSandboxLabel: "沙箱",
pathAllowPrefixLabel: "允许前缀",
runOnce: "运行一次",
alwaysAllow: '始终允许 "{prefix}"',
deny: "拒绝",
+40 -11
View File
@@ -4,7 +4,7 @@ import { h } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { ChatMessage, type ChatMsg, ToolCard, parseToolArgs } from "../components/chat-internals.js";
import { t, useLang } from "../i18n/index.js";
import { TOKEN, api } from "../lib/api.js";
import { MODE, TOKEN, api } from "../lib/api.js";
import { showToast } from "../lib/bus.js";
import { parseHunks } from "../lib/diff-parser.js";
// ChatStatusBar — inlined (mirrors chat.ts pattern)
@@ -1453,7 +1453,7 @@ interface PopoverItem {
insert: string;
}
type PopoverKind = "slash" | null;
type PopoverKind = "slash" | "mention" | null;
interface ChatPaneProps {
comments: LineComment[];
@@ -1716,6 +1716,10 @@ function ChatPane(props: ChatPaneProps) {
const updatePopover = useCallback(
async (text: string) => {
if (busy) {
setPopoverKind(null);
return;
}
const slashMatch = /^\/([A-Za-z0-9_-]*)$/.exec(text);
if (slashMatch) {
const prefix = slashMatch[1]!.toLowerCase();
@@ -1732,15 +1736,42 @@ function ChatPane(props: ChatPaneProps) {
setPopoverSel(0);
return;
}
const mentionMatch = /(?:^|\s)@([^\s@]*)$/.exec(text);
if (mentionMatch && MODE === "attached") {
const prefix = mentionMatch[1] ?? "";
try {
const r = await api<{ files: string[] }>("/files", {
method: "POST",
body: { prefix },
});
const items: PopoverItem[] = r.files.slice(0, 12).map((f) => ({
label: f,
insert: `@${f} `,
}));
setPopoverKind("mention");
setPopoverItems(items);
setPopoverSel(0);
} catch {
setPopoverKind(null);
}
return;
}
setPopoverKind(null);
},
[slashCommands],
[busy, slashCommands],
);
const applyPopover = useCallback(() => {
const item = popoverItems[popoverSel];
if (!item) return false;
setInput(item.insert);
if (popoverKind === "slash") {
setInput(item.insert);
} else if (popoverKind === "mention") {
const m = /(?:^|\s)@([^\s@]*)$/.exec(input);
if (!m) return false;
const start = input.length - m[0].length + (m[0].startsWith(" ") ? 1 : 0);
setInput(`${input.slice(0, start)}${item.insert}`);
}
setPopoverKind(null);
return true;
}, [popoverItems, popoverSel, popoverKind, input]);
@@ -1757,7 +1788,6 @@ function ChatPane(props: ChatPaneProps) {
const send = useCallback(async () => {
const text = input.trim();
if (busy) return;
if (!text && props.comments.length === 0) return;
setError(null);
@@ -1783,7 +1813,7 @@ function ChatPane(props: ChatPaneProps) {
} catch (err) {
setError((err as Error).message);
}
}, [input, busy, props.comments]);
}, [input, props.comments]);
const abort = useCallback(async () => {
try {
@@ -1949,7 +1979,7 @@ function ChatPane(props: ChatPaneProps) {
popoverKind && popoverItems.length > 0
? html`
<div class="popover" style="position:absolute;bottom:calc(100% + 6px);left:0;width:380px;max-height:280px;overflow-y:auto;z-index:10">
<div class="popover-h">${t("chat.slashCommands")}</div>
<div class="popover-h">${popoverKind === "slash" ? t("chat.slashCommands") : t("chat.projectFiles")}</div>
${popoverItems.map(
(it, i) => html`
<div
@@ -1960,7 +1990,7 @@ function ChatPane(props: ChatPaneProps) {
applyPopover();
}}
>
<span class="g">/</span>
<span class="g">${popoverKind === "slash" ? "/" : "@"}</span>
<span class="name">${it.label}</span>
${it.meta ? html`<span class="meta">${it.meta}</span>` : null}
</div>
@@ -1973,19 +2003,18 @@ function ChatPane(props: ChatPaneProps) {
<textarea
class="input"
style=${{ width: "100%", resize: "none", minHeight: "36px", fontFamily: "inherit", fontSize: "13px", padding: "8px 10px", lineHeight: "1.4", background: "var(--bg-input)", border: "1px solid var(--bd)", borderRadius: "4px", color: "var(--fg-0)" }}
placeholder=${busy ? t("chat.placeholderBusy") : props.comments.length > 0 ? "总结评论..." : t("changes.chatPlaceholder")}
placeholder=${busy ? t("chat.placeholderSteerBusy") : props.comments.length > 0 ? "总结评论..." : t("changes.chatPlaceholder")}
value=${input}
onInput=${onInput}
onKeyDown=${onKeyDown}
onCompositionStart=${onCompositionStart}
onCompositionEnd=${onCompositionEnd}
onBlur=${() => setTimeout(() => setPopoverKind(null), 150)}
disabled=${busy}
rows="2"
/>
</div>
<div style=${{ display: "flex", flexDirection: "column", gap: "6px", flexShrink: 0 }}>
<button class="primary" onClick=${send} disabled=${busy || (!input.trim() && props.comments.length === 0)} style=${{ padding: "8px 12px", borderRadius: "4px" }}>${t("changes.chatSend")}</button>
<button class="primary" onClick=${send} disabled=${!input.trim() && props.comments.length === 0} style=${{ padding: "8px 12px", borderRadius: "4px" }}>${t("changes.chatSend")}</button>
<div style=${{ display: "flex", gap: "6px" }}>
<button onClick=${newConversation} title=${t("changes.newTitle")}>${t("changes.newConversation")}</button>
<button onClick=${clearScrollback} title=${t("changes.clearTitle")}>${t("changes.clearConversation")}</button>
+12 -6
View File
@@ -7,6 +7,7 @@ import {
ChoiceModal,
EditReviewModal,
type OnResolve,
PathModal,
PickerModal,
PlanModal,
RevisionModal,
@@ -626,6 +627,8 @@ export function ChatPanel() {
modal
? modal.kind === "shell"
? html`<${ShellModal} modal=${modal} onResolve=${resolveModal} />`
: modal.kind === "path"
? html`<${PathModal} modal=${modal} onResolve=${resolveModal} />`
: modal.kind === "choice"
? html`<${ChoiceModal} modal=${modal} onResolve=${resolveModal} />`
: modal.kind === "plan"
@@ -705,6 +708,10 @@ const ChatInput = memo(function ChatInput({
const updatePopover = useCallback(
async (text: string) => {
if (busy) {
setPopoverKind(null);
return;
}
const slashMatch = /^\/([A-Za-z0-9_-]*)$/.exec(text);
if (slashMatch) {
const prefix = slashMatch[1]!.toLowerCase();
@@ -743,7 +750,7 @@ const ChatInput = memo(function ChatInput({
}
setPopoverKind(null);
},
[slashCommands],
[busy, slashCommands],
);
const applyPopover = useCallback(() => {
@@ -763,10 +770,10 @@ const ChatInput = memo(function ChatInput({
const send = useCallback(async () => {
const text = input.trim();
if (!text || busy) return;
if (!text) return;
const res = await onSubmit(text);
if (res.accepted) setInput("");
}, [input, busy, onSubmit]);
}, [input, onSubmit]);
const onInput = useCallback(
(e: Event) => {
@@ -855,21 +862,20 @@ const ChatInput = memo(function ChatInput({
: null
}
<textarea
placeholder=${busy ? t("chat.placeholderBusy") : t("chat.placeholder")}
placeholder=${busy ? t("chat.placeholderSteerBusy") : t("chat.placeholder")}
value=${input}
onInput=${onInput}
onKeyDown=${onKeyDown}
onCompositionStart=${onCompositionStart}
onCompositionEnd=${onCompositionEnd}
onBlur=${() => setTimeout(() => setPopoverKind(null), 150)}
disabled=${busy}
rows="2"
></textarea>
<div style="display: flex; flex-direction: column; gap: 6px; align-self: stretch; justify-content: flex-end;">
<button
class="primary"
onClick=${send}
disabled=${busy || !input.trim()}
disabled=${!input.trim()}
>${t("chat.send")}</button>
<div style="display: flex; gap: 6px;">
<button onClick=${onNew} title=${t("chat.newTitle")}>${t("chat.new")}</button>
+24
View File
@@ -23,6 +23,7 @@ interface SettingsData {
preset?: string;
reasoningEffort?: string;
search?: boolean;
webSearchEngine?: string;
model?: string;
editMode?: string;
proNext?: boolean;
@@ -631,6 +632,29 @@ export function SettingsPanel() {
`,
t("settings.webSearchNote"),
)}
${fieldRow(
t("settings.webSearchEngine"),
html`
<select
value=${["bing", "searxng", "metaso", "tavily", "perplexity", "exa"].includes(
v.webSearchEngine ?? "",
)
? v.webSearchEngine
: "bing"}
onChange=${(e: Event) =>
save({ webSearchEngine: (e.target as HTMLSelectElement).value })}
disabled=${saving || !v.search}
>
<option value="bing">${t("settings.webSearchEngineBing")}</option>
<option value="searxng">${t("settings.webSearchEngineSearxng")}</option>
<option value="metaso">${t("settings.webSearchEngineMetaso")}</option>
<option value="tavily">${t("settings.webSearchEngineTavily")}</option>
<option value="perplexity">${t("settings.webSearchEnginePerplexity")}</option>
<option value="exa">${t("settings.webSearchEngineExa")}</option>
</select>
`,
t("settings.webSearchEngineNote"),
)}
</div>
${sectionH3(t("settings.sectionSkills"))}
+25 -2
View File
@@ -1,18 +1,20 @@
{
"name": "reasonix-desktop",
"version": "0.43.0",
"version": "0.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "reasonix-desktop",
"version": "0.43.0",
"version": "0.49.0",
"dependencies": {
"@fontsource/geist": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@reasonix/core-utils": "file:../packages/core-utils",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-notification": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
@@ -37,6 +39,14 @@
"vite": "^5.4.10"
}
},
"../packages/core-utils": {
"name": "@reasonix/core-utils",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^22.9.0",
"typescript": "^5.6.3"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -787,6 +797,10 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reasonix/core-utils": {
"resolved": "../packages/core-utils",
"link": true
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1380,6 +1394,15 @@
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "reasonix-desktop",
"private": true,
"version": "0.48.1",
"version": "0.49.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,9 +14,10 @@
"@fontsource/geist": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@reasonix/core-utils": "workspace:*",
"@reasonix/core-utils": "file:../packages/core-utils",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-notification": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
+127 -2
View File
@@ -2041,6 +2041,18 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mac-notification-sys"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
dependencies = [
"cc",
"objc2",
"objc2-foundation",
"time",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2151,6 +2163,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "notify-rust"
version = "4.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -2600,7 +2626,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.39.4",
"serde",
"time",
]
@@ -2660,6 +2686,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2738,6 +2773,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -2768,6 +2812,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2776,7 +2849,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "reasonix-desktop"
version = "0.48.1"
version = "0.49.0"
dependencies = [
"anyhow",
"parking_lot",
@@ -2785,6 +2858,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
@@ -3801,6 +3875,25 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -3981,6 +4074,18 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5531,6 +5636,26 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerofrom"
version = "0.1.7"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "reasonix-desktop"
version = "0.48.1"
version = "0.49.0"
edition = "2021"
publish = false
@@ -13,6 +13,7 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-notification = "2"
tauri-plugin-window-state = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -17,6 +17,7 @@
"process:allow-restart",
"dialog:allow-open",
"dialog:allow-save",
"notification:default",
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "https://*" }, { "url": "http://*" }]
+1
View File
@@ -217,6 +217,7 @@ fn main() {
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.manage(RpcState::default())
.invoke_handler(tauri::generate_handler![
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Reasonix",
"version": "0.48.1",
"version": "0.49.0",
"identifier": "dev.reasonix.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+10 -1
View File
@@ -49,7 +49,16 @@ function initialState(): Parameters<typeof reduce>[0] {
pendingCheckpoints: [],
pendingRevisions: [],
activePlan: null,
usage: { totalCostUsd: 0, tokens: { input: 0, output: 0, cacheHit: 0, cacheMiss: 0 } },
usage: {
totalCostUsd: 0,
totalPromptTokens: 0,
totalCompletionTokens: 0,
cacheHitTokens: 0,
cacheMissTokens: 0,
lastCallCacheHit: null,
lastCallCacheMiss: null,
reservedTokens: 0,
},
sessions: [],
settings: null,
qq: null,
+65
View File
@@ -2,6 +2,11 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { open as openDialog, save as saveDialog } from "@tauri-apps/plugin-dialog";
import {
isPermissionGranted as isNotificationPermissionGranted,
requestPermission as requestNotificationPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
import { relaunch } from "@tauri-apps/plugin-process";
import { type Update, check } from "@tauri-apps/plugin-updater";
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
@@ -51,6 +56,7 @@ import { Sidebar } from "./ui/sidebar";
import { Shortcut, localizeShortcutText, shortcutText } from "./ui/shortcut";
import { Splash, shouldShowSplash } from "./ui/splash";
import { StatusBar } from "./ui/statusbar";
import { dispatchDesktopNotifications, deriveDesktopNotifications, type ApprovalSnapshot } from "./notifications";
import {
ActivePlanTaskCard,
AssistantMsg,
@@ -852,6 +858,7 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
`Session "${ev.name}" loaded with no messages (${sizeNote}). ` +
`The file ~/.reasonix/sessions/${ev.name}.jsonl exists but couldn't be parsed — ` +
`start a new chat or restore from .jsonl.bak if you have one.`,
id: nextErrorId(),
},
],
};
@@ -1169,6 +1176,16 @@ function TabRuntime({
const [settingsPage, setSettingsPage] = useState<SettingsPageId>("general");
const [jobsOpen, setJobsOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const previousApprovalSnapshotRef = useRef<ApprovalSnapshot>({
confirms: [],
pathAccess: [],
choices: [],
plans: [],
checkpoints: [],
revisions: [],
});
const wasBusyRef = useRef(false);
const busyStartedAtRef = useRef<number | null>(null);
const openSettingsAt = useCallback((page: SettingsPageId = "general") => {
setSettingsPage(page);
setSettingsOpen(true);
@@ -1386,6 +1403,54 @@ function TabRuntime({
send(next);
}, [state.busy, state.ready, state.queuedSends, send]);
useEffect(() => {
const currentSnapshot: ApprovalSnapshot = {
confirms: state.pendingConfirms.map((c) => ({ id: c.id, command: c.command })),
pathAccess: state.pendingPathAccess.map((p) => ({ id: p.id, path: p.path, intent: p.intent })),
choices: state.pendingChoices.map((c) => ({ id: c.id, question: c.question })),
plans: state.pendingPlans.map((p) => ({ id: p.id, summary: p.summary, plan: p.plan })),
checkpoints: state.pendingCheckpoints.map((c) => ({ id: c.id, title: c.title, result: c.result })),
revisions: state.pendingRevisions.map((r) => ({ id: r.id, summary: r.summary, reason: r.reason })),
};
const previousSnapshot = previousApprovalSnapshotRef.current;
const wasBusy = wasBusyRef.current;
const busyDurationMs = wasBusy && !state.busy && busyStartedAtRef.current
? Date.now() - busyStartedAtRef.current
: 0;
if (state.busy && busyStartedAtRef.current === null) {
busyStartedAtRef.current = Date.now();
} else if (!state.busy) {
busyStartedAtRef.current = null;
}
previousApprovalSnapshotRef.current = currentSnapshot;
wasBusyRef.current = state.busy;
const notifications = deriveDesktopNotifications({
previous: previousSnapshot,
current: currentSnapshot,
wasBusy,
isBusy: state.busy,
busyDurationMs,
focused: false,
});
void dispatchDesktopNotifications(notifications, {
isFocused: () => getCurrentWindow().isFocused(),
isPermissionGranted: isNotificationPermissionGranted,
requestPermission: requestNotificationPermission,
sendNotification,
});
}, [
state.busy,
state.pendingChoices,
state.pendingCheckpoints,
state.pendingConfirms,
state.pendingPathAccess,
state.pendingPlans,
state.pendingRevisions,
]);
const resolveConfirm = useCallback(
(id: number, response: ConfirmationChoice) => {
sendRpc({ cmd: "confirm_response", id, response });
+12
View File
@@ -680,4 +680,16 @@ export const en = {
panel: {
renderError: "{panel} panel render error",
},
notifications: {
approvalTitle: "Reasonix is waiting for approval",
inputTitle: "Reasonix is waiting for input",
planApprovalTitle: "Reasonix is waiting for plan approval",
checkpointApprovalTitle: "Reasonix is waiting for checkpoint approval",
revisionTitle: "Reasonix proposed a plan revision",
commandBody: "Command: {command}",
readBody: "Read: {path}",
writeBody: "Write: {path}",
turnCompleteTitle: "Reasonix task complete",
turnCompleteBody: "The current task finished and is ready for review.",
},
};
+12
View File
@@ -666,4 +666,16 @@ export const zhCN: typeof en = {
panel: {
renderError: "{panel} 面板渲染错误",
},
notifications: {
approvalTitle: "\u7b49\u5f85\u6279\u51c6",
inputTitle: "\u7b49\u5f85\u4f60\u8f93\u5165",
planApprovalTitle: "\u7b49\u5f85\u8ba1\u5212\u6279\u51c6",
checkpointApprovalTitle: "\u7b49\u5f85\u68c0\u67e5\u70b9\u6279\u51c6",
revisionTitle: "\u5df2\u63d0\u51fa\u8ba1\u5212\u4fee\u8ba2",
commandBody: "\u547d\u4ee4\uff1a{command}",
readBody: "\u8bfb\u53d6\uff1a{path}",
writeBody: "\u5199\u5165\uff1a{path}",
turnCompleteTitle: "\u4efb\u52a1\u5df2\u5b8c\u6210",
turnCompleteBody: "\u5f53\u524d\u4efb\u52a1\u5df2\u7ed3\u675f\uff0c\u53ef\u4ee5\u56de\u6765\u67e5\u770b\u7ed3\u679c\u4e86\u3002",
},
};
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { setLang } from "./i18n";
import {
COMPLETION_NOTIFY_MIN_MS,
deriveDesktopNotifications,
type ApprovalSnapshot,
} from "./notifications";
function emptySnapshot(): ApprovalSnapshot {
return {
confirms: [],
pathAccess: [],
choices: [],
plans: [],
checkpoints: [],
revisions: [],
};
}
describe("desktop notifications", () => {
it("uses the active UI language for notification copy", () => {
setLang("zh-CN");
const next = emptySnapshot();
next.confirms.push({ id: 1, command: "git push" });
const notifications = deriveDesktopNotifications({
previous: emptySnapshot(),
current: next,
wasBusy: true,
isBusy: true,
busyDurationMs: 1_000,
focused: false,
});
expect(notifications).toEqual([
{
kind: "approval",
title: "等待批准",
body: "命令:git push",
},
]);
setLang("en");
});
it("notifies when a new approval request appears while unfocused", () => {
const next = emptySnapshot();
next.confirms.push({ id: 1, command: "git push" });
const notifications = deriveDesktopNotifications({
previous: emptySnapshot(),
current: next,
wasBusy: true,
isBusy: true,
busyDurationMs: 1_000,
focused: false,
});
expect(notifications).toEqual([
{
kind: "approval",
title: "Reasonix is waiting for approval",
body: "Command: git push",
},
]);
});
it("does not notify approvals when the window is focused", () => {
const next = emptySnapshot();
next.pathAccess.push({ id: 2, path: "/tmp/out.txt", intent: "write" });
const notifications = deriveDesktopNotifications({
previous: emptySnapshot(),
current: next,
wasBusy: true,
isBusy: true,
busyDurationMs: 1_000,
focused: true,
});
expect(notifications).toEqual([]);
});
it("notifies on long task completion when the window is unfocused", () => {
const notifications = deriveDesktopNotifications({
previous: emptySnapshot(),
current: emptySnapshot(),
wasBusy: true,
isBusy: false,
busyDurationMs: COMPLETION_NOTIFY_MIN_MS,
focused: false,
});
expect(notifications).toEqual([
{
kind: "turn_complete",
title: "Reasonix task complete",
body: "The current task finished and is ready for review.",
},
]);
});
it("does not notify on short task completion", () => {
const notifications = deriveDesktopNotifications({
previous: emptySnapshot(),
current: emptySnapshot(),
wasBusy: true,
isBusy: false,
busyDurationMs: COMPLETION_NOTIFY_MIN_MS - 1,
focused: false,
});
expect(notifications).toEqual([]);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { t } from "./i18n";
export type ApprovalSnapshot = {
confirms: { id: number; command: string }[];
pathAccess: { id: number; path: string; intent: "read" | "write" }[];
choices: { id: number; question: string }[];
plans: { id: number; summary?: string; plan: string }[];
checkpoints: { id: number; title?: string; result: string }[];
revisions: { id: number; summary?: string; reason: string }[];
};
export type DesktopNotification =
| { kind: "approval"; title: string; body: string }
| { kind: "turn_complete"; title: string; body: string };
export const COMPLETION_NOTIFY_MIN_MS = 15_000;
function newestApproval(snapshot: ApprovalSnapshot): DesktopNotification | null {
const confirm = snapshot.confirms.at(-1);
if (confirm) {
return {
kind: "approval",
title: t("notifications.approvalTitle"),
body: t("notifications.commandBody", { command: confirm.command }),
};
}
const pathAccess = snapshot.pathAccess.at(-1);
if (pathAccess) {
return {
kind: "approval",
title: t("notifications.approvalTitle"),
body: t(
pathAccess.intent === "write" ? "notifications.writeBody" : "notifications.readBody",
{ path: pathAccess.path },
),
};
}
const choice = snapshot.choices.at(-1);
if (choice) {
return {
kind: "approval",
title: t("notifications.inputTitle"),
body: choice.question,
};
}
const plan = snapshot.plans.at(-1);
if (plan) {
return {
kind: "approval",
title: t("notifications.planApprovalTitle"),
body: plan.summary ?? plan.plan,
};
}
const checkpoint = snapshot.checkpoints.at(-1);
if (checkpoint) {
return {
kind: "approval",
title: t("notifications.checkpointApprovalTitle"),
body: checkpoint.title ?? checkpoint.result,
};
}
const revision = snapshot.revisions.at(-1);
if (revision) {
return {
kind: "approval",
title: t("notifications.revisionTitle"),
body: revision.summary ?? revision.reason,
};
}
return null;
}
export function totalPending(snapshot: ApprovalSnapshot): number {
return (
snapshot.confirms.length +
snapshot.pathAccess.length +
snapshot.choices.length +
snapshot.plans.length +
snapshot.checkpoints.length +
snapshot.revisions.length
);
}
export function deriveDesktopNotifications(args: {
previous: ApprovalSnapshot;
current: ApprovalSnapshot;
wasBusy: boolean;
isBusy: boolean;
busyDurationMs: number;
focused: boolean;
}): DesktopNotification[] {
const notifications: DesktopNotification[] = [];
if (args.focused) return notifications;
if (totalPending(args.current) > totalPending(args.previous)) {
const approval = newestApproval(args.current);
if (approval) notifications.push(approval);
}
if (args.wasBusy && !args.isBusy && args.busyDurationMs >= COMPLETION_NOTIFY_MIN_MS) {
notifications.push({
kind: "turn_complete",
title: t("notifications.turnCompleteTitle"),
body: t("notifications.turnCompleteBody"),
});
}
return notifications;
}
let notificationPermissionAttempted = false;
export async function dispatchDesktopNotifications(
notifications: DesktopNotification[],
deps: {
isFocused: () => Promise<boolean>;
isPermissionGranted: () => Promise<boolean>;
requestPermission: () => Promise<"default" | "denied" | "granted">;
sendNotification: (note: { title: string; body: string }) => void;
},
): Promise<void> {
if (notifications.length === 0) return;
const focused = await deps.isFocused().catch(() => true);
if (focused) return;
let granted = await deps.isPermissionGranted().catch(() => false);
if (!granted && !notificationPermissionAttempted) {
notificationPermissionAttempted = true;
const permission = await deps.requestPermission().catch(() => "denied" as const);
granted = permission === "granted";
}
if (!granted) return;
for (const note of notifications) {
deps.sendNotification({ title: note.title, body: note.body });
}
}
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "reasonix",
"version": "0.48.0",
"version": "0.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "reasonix",
"version": "0.48.0",
"version": "0.49.0",
"license": "MIT",
"workspaces": [
"packages/*"
@@ -15,6 +15,7 @@
"cli-highlight": "^2.1.11",
"commander": "^12.1.0",
"eventsource-parser": "^3.0.0",
"iconv-lite": "^0.7.2",
"ignore": "^7.0.5",
"ink": "^7.0.2",
"ink-text-input": "^6.0.0",
@@ -4171,7 +4172,6 @@
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -5403,9 +5403,9 @@
}
},
"node_modules/reasonix": {
"version": "0.48.0",
"resolved": "https://registry.npmjs.org/reasonix/-/reasonix-0.48.0.tgz",
"integrity": "sha512-Y7WRpqONVV+OrrvEecRRUs8U9LpvFhufmi2w4XXuZkR8rWN5n0NcosB8+Zn00YQFW2JI9N2lbHLXWJtBk+jZtw==",
"version": "0.48.1",
"resolved": "https://registry.npmjs.org/reasonix/-/reasonix-0.48.1.tgz",
"integrity": "sha512-TLosbhvYtva8GUjoYdCFMmzsA00go5mMJSsRvidA7NBUUyNj40izkN3QysYDalphOqW02x2boOymoabjtE6imA==",
"license": "MIT",
"workspaces": [
"packages/*"
@@ -5427,6 +5427,7 @@
"zod": "^4.4.1"
},
"bin": {
"dsnix": "dist/cli/index.js",
"reasonix": "dist/cli/index.js"
},
"engines": {
@@ -5553,7 +5554,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
@@ -7954,10 +7954,10 @@
}
},
"packages/dsnix": {
"version": "0.48.0",
"version": "0.48.1",
"license": "MIT",
"dependencies": {
"reasonix": "0.48.0"
"reasonix": "0.48.1"
},
"bin": {
"dsnix": "bin.cjs"
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.48.1",
"version": "0.49.0",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
@@ -75,6 +75,7 @@
"cli-highlight": "^2.1.11",
"commander": "^12.1.0",
"eventsource-parser": "^3.0.0",
"iconv-lite": "^0.7.2",
"ignore": "^7.0.5",
"ink": "^7.0.2",
"ink-text-input": "^6.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dsnix",
"version": "0.48.0",
"version": "0.48.1",
"description": "Short alias for reasonix — DeepSeek-native coding agent. Forwards to the reasonix CLI.",
"bin": {
"dsnix": "bin.cjs"
@@ -31,6 +31,6 @@
"node": ">=22"
},
"dependencies": {
"reasonix": "0.48.0"
"reasonix": "0.48.1"
}
}
+6 -4
View File
@@ -1,6 +1,6 @@
import { render } from "ink";
import React, { useMemo, useState } from "react";
import { loadApiKey, readConfig, searchEnabled } from "../../config.js";
import { loadApiKey, loadToolRateLimit, readConfig, searchEnabled } from "../../config.js";
import { loadDotenv } from "../../env.js";
import { t } from "../../i18n/index.js";
import {
@@ -261,7 +261,9 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
// replacing. When no seed AND no MCP, tools stays undefined and
// the loop runs as a bare chat.
let tools: ToolRegistry | undefined = opts.seedTools;
if (requestedSpecs.length > 0 && !tools) tools = new ToolRegistry();
if (requestedSpecs.length > 0 && !tools) {
tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
}
const runtime = createMcpRuntime({
getTools: () => tools,
@@ -287,7 +289,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
// backs them with no key required; the model invokes them whenever
// a question needs info fresher than its training data.
if (searchEnabled()) {
if (!tools) tools = new ToolRegistry();
if (!tools) tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
registerWebTools(tools);
}
@@ -299,7 +301,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
// exists) so it can wire the subagent runner for runAs:subagent
// skills.
if (!opts.seedTools) {
if (!tools) tools = new ToolRegistry();
if (!tools) tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
registerMemoryTools(tools, {});
// `ask_choice` — branching primitive, useful in chat too (stylistic
// preferences, doc language, library picks). Independent of plan
+4 -1
View File
@@ -91,7 +91,10 @@ function checkProxy(): Check[] {
},
];
}
const resolved = resolveNoProxy(process.env, { extraNoProxy: cfg.noProxy });
const resolved = resolveNoProxy(process.env, {
extraNoProxy: cfg.noProxy,
bypassDeepSeekDirect: cfg.bypassDeepSeekDirect,
});
const total = resolved.all.length;
const sourceSummary = [
`defaults ${resolved.defaults.length}`,
+31 -24
View File
@@ -1,4 +1,5 @@
import { defaultConfigPath, readConfig, writeConfig } from "../../config.js";
import { t } from "../../i18n/index.js";
import { MCP_CATALOG, mcpCommandFor } from "../../mcp/catalog.js";
import {
type FetchProgress,
@@ -95,7 +96,7 @@ export async function mcpListCommand(opts: McpListOptions = {}): Promise<void> {
console.log(JSON.stringify(MCP_CATALOG, null, 2));
return;
}
console.log("Bundled MCP servers (offline catalog):");
console.log(t("mcpCli.bundledCatalog"));
console.log("");
for (const entry of MCP_CATALOG) {
console.log(` ${pad(entry.name, 12)} ${entry.summary}`);
@@ -141,9 +142,9 @@ export async function mcpListCommand(opts: McpListOptions = {}): Promise<void> {
}
const ageStr = result.fromCache
? `cached, ${fmtAge(Date.now() - result.fetchedAt)}`
: "just fetched";
const moreStr = result.hasMore ? "more available" : "all loaded";
? t("mcpCli.cachedAge", { age: fmtAge(Date.now() - result.fetchedAt) })
: t("mcpCli.justFetched");
const moreStr = result.hasMore ? t("mcpCli.moreAvailable") : t("mcpCli.allLoaded");
console.log(
`MCP servers from ${result.source} registry (${result.entries.length} loaded, ${moreStr}, ${ageStr}):`,
);
@@ -153,15 +154,13 @@ export async function mcpListCommand(opts: McpListOptions = {}): Promise<void> {
console.log("");
for (const e of shown) printEntry(e);
if (ranked.length > limit) {
console.log(
` … ${ranked.length - limit} more loaded — use \`reasonix mcp search <query>\` to filter`,
);
console.log(t("mcpCli.moreLoaded", { count: ranked.length - limit }));
}
if (result.hasMore) {
console.log(" ▸ more pages available — `reasonix mcp list --pages <n>` or --all");
console.log(t("mcpCli.morePagesAvailable"));
}
console.log("");
console.log("Install: reasonix mcp install <name>");
console.log(t("mcpCli.installHint"));
}
function matchFilter(query: string): (e: RegistryEntry) => boolean {
@@ -172,7 +171,7 @@ function matchFilter(query: string): (e: RegistryEntry) => boolean {
export async function mcpSearchCommand(query: string, opts: McpSearchOptions = {}): Promise<void> {
const q = query.trim();
if (!q) {
console.error("usage: reasonix mcp search <query>");
console.error(t("mcpCli.usageSearch"));
process.exit(1);
}
const handle = await openRegistry({ noCache: opts.refresh, onProgress: progressToStderr });
@@ -212,18 +211,22 @@ export async function mcpSearchCommand(query: string, opts: McpSearchOptions = {
if (shown.length === 0) {
console.log(
`No matches for "${q}" across ${result.entries.length} loaded entries (${result.source}${
result.hasMore ? ", more pages exist — try --refresh or `mcp list --all`" : ""
}).`,
t("mcpCli.noMatchesFor", { q, count: result.entries.length, source: result.source }),
);
return;
}
console.log(
`${matches.length} match(es) for "${q}" in ${result.source} registry (${result.entries.length} entries scanned):`,
t("mcpCli.matchCount", {
count: matches.length,
q,
source: result.source,
loaded: result.entries.length,
}),
);
console.log("");
for (const e of shown) printEntry(e);
if (matches.length > limit) console.log(` … ${matches.length - limit} more matches`);
if (matches.length > limit)
console.log(t("mcpCli.moreMatches", { count: matches.length - limit }));
}
function findEntry(entries: RegistryEntry[], name: string): RegistryEntry | null {
@@ -240,7 +243,7 @@ function findEntry(entries: RegistryEntry[], name: string): RegistryEntry | null
export async function mcpInstallCommand(name: string, opts: McpInstallOptions = {}): Promise<void> {
const target = name.trim();
if (!target) {
console.error("usage: reasonix mcp install <name>");
console.error(t("mcpCli.usageInstall"));
process.exit(1);
}
@@ -263,10 +266,14 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
const entry = findEntry(handle.cache.entries, target);
if (!entry) {
console.error(
`No MCP server named "${target}" found after walking ${handle.cache.pagination.pagesLoaded} page(s) of the ${handle.source} registry.`,
t("mcpCli.noServerFound", {
target,
pages: handle.cache.pagination.pagesLoaded,
source: handle.source,
}),
);
if (handle.cache.pagination.nextCursor !== null) {
console.error(`Try: reasonix mcp install ${target} --max-pages 100`);
console.error(t("mcpCli.noServerTryMore", { target }));
}
process.exit(1);
}
@@ -278,9 +285,7 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
}
if (!entry.install) {
console.error(
`Could not derive install metadata for "${entry.name}" — try \`npx -y @smithery/cli install ${entry.name}\` directly.`,
);
console.error(t("mcpCli.noInstallMeta", { name: entry.name }));
process.exit(1);
}
@@ -288,20 +293,22 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
try {
spec = specStringFor(entry.name, entry.install);
} catch (err) {
console.error(`Cannot build install spec for ${entry.name}: ${(err as Error).message}`);
console.error(
t("mcpCli.buildSpecFailed", { name: entry.name, message: (err as Error).message }),
);
process.exit(1);
}
const cfg = readConfig();
const existing = cfg.mcp ?? [];
if (existing.includes(spec)) {
console.log(`Already installed: ${spec}`);
console.log(t("mcpCli.alreadyInstalled", { spec }));
return;
}
const next = { ...cfg, mcp: [...existing, spec] };
writeConfig(next);
console.log(`Installed: ${entry.name}`);
console.log(t("mcpCli.installed", { spec: entry.name }));
console.log(` spec: ${spec}`);
const installedName = parseInstalledName(spec);
if (entry.install.requiredEnv?.length) {
+7 -6
View File
@@ -1,3 +1,4 @@
import { t } from "../../i18n/index.js";
import { listSessions, pruneStaleSessions } from "../../memory/session.js";
export interface PruneSessionsOptions {
@@ -8,30 +9,30 @@ export interface PruneSessionsOptions {
export function pruneSessionsCommand(opts: PruneSessionsOptions): void {
const days = opts.days ?? 90;
if (!Number.isFinite(days) || days < 1) {
console.error(`--days must be a positive integer (got ${days}).`);
console.error(t("sessions.daysInvalid", { days }));
process.exit(1);
}
if (opts.dryRun) {
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const stale = listSessions().filter((s) => s.mtime.getTime() < cutoff);
if (stale.length === 0) {
console.log(`no sessions idle ≥${days} days. Nothing would be pruned.`);
console.log(t("sessions.noIdleSessions", { days }));
return;
}
console.log(`would prune ${stale.length} session(s) idle ≥${days} days:`);
console.log(t("sessions.wouldPrune", { count: stale.length, days }));
for (const s of stale) {
console.log(` ${s.name}`);
}
console.log("");
console.log("re-run without --dry-run to actually delete.");
console.log(t("sessions.dryRunHint"));
return;
}
const removed = pruneStaleSessions(days);
if (removed.length === 0) {
console.log(`no sessions idle ≥${days} days. Nothing pruned.`);
console.log(t("sessions.noIdleSessions", { days }));
return;
}
console.log(`pruned ${removed.length} session(s) idle ≥${days} days:`);
console.log(t("sessions.prunedCount", { count: removed.length, days }));
for (const name of removed) {
console.log(` ${name}`);
}
+2 -1
View File
@@ -6,6 +6,7 @@ import {
isPlausibleKey,
loadApiKey,
loadBaseUrl,
loadToolRateLimit,
normalizeMcpConfig,
readConfig,
saveApiKey,
@@ -82,7 +83,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
let tools: ToolRegistry | undefined;
let successCount = 0;
if (normalizedSpecs.length > 0) {
tools = new ToolRegistry();
tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
for (const spec of normalizedSpecs) {
let label = "anon";
let mcp: McpClient | undefined;
+5 -5
View File
@@ -23,7 +23,7 @@ function listAll(): void {
console.log(t("sessions.emptyHint"));
return;
}
console.log("Saved sessions (~/.reasonix/sessions/):");
console.log(t("sessions.listHeader"));
console.log("");
console.log(` ${"name".padEnd(22)} ${"msgs".padStart(6)} ${"size".padStart(8)} modified`);
console.log(` ${"─".repeat(60)}`);
@@ -37,16 +37,16 @@ function listAll(): void {
if (details.length > 0) console.log(` ${details.join(" · ")}`);
}
console.log("");
console.log("Inspect: reasonix sessions <name>");
console.log("Resume: reasonix chat --session <name>");
console.log(t("sessions.inspectHint"));
console.log(t("sessions.resumeHint"));
}
function inspectSession(name: string, verbose: boolean): void {
const path = sessionPath(name);
const messages = loadSessionMessages(name);
if (messages.length === 0) {
console.error(`no session named "${name}" (or it's empty).`);
console.error(`looked at: ${path}`);
console.error(t("sessions.noSession", { name }));
console.error(t("sessions.lookedAt", { path }));
process.exit(1);
}
+1
View File
@@ -31,6 +31,7 @@ const cfgProxy = loadProxyConfig();
installProxyIfConfigured(process.env, {
disabled: cliNoProxy || cfgProxy.disabled === true,
extraNoProxy: cfgProxy.noProxy,
bypassDeepSeekDirect: cfgProxy.bypassDeepSeekDirect,
});
markPhase("cli_module_loaded");
+488 -529
View File
File diff suppressed because it is too large. Load diff
-4
View File
@@ -5,7 +5,6 @@ import { t } from "../../i18n/index.js";
import type { ChoiceOption } from "../../tools/choice.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useReserveRows } from "./layout/viewport-budget.js";
export type ChoiceConfirmChoice =
| { kind: "pick"; optionId: string }
@@ -23,9 +22,6 @@ const CUSTOM_VALUE = "__custom__";
const CANCEL_VALUE = "__cancel__";
function ChoiceConfirmInner({ question, options, allowCustom, onChoose }: ChoiceConfirmProps) {
const optionRows = options.length + (allowCustom ? 1 : 0) + 1; // +1 for cancel
useReserveRows("modal", { min: 6, max: Math.max(10, optionRows + 6) });
const items: Array<{ value: string; label: string; hint?: string }> = options.map((opt) => ({
value: opt.id,
label: `${opt.id} · ${opt.title}`,
+5 -24
View File
@@ -3,9 +3,8 @@
* Extracted from App.tsx per #565 Phase 2.
*/
import { Box, Text, useStdout } from "ink";
import { Box, Text } from "ink";
import React from "react";
import stringWidth from "string-width";
import type { EditMode } from "../../config.js";
import type { JobRegistry } from "../../tools/jobs.js";
@@ -20,8 +19,6 @@ import { SlashSuggestions } from "./SlashSuggestions.js";
import { StatusRow } from "./layout/StatusRow.js";
import { formatLoopStatus } from "./loop.js";
import { useChatScrollState } from "./state/chat-scroll-provider.js";
import { FG, SURFACE } from "./theme/tokens.js";
import type { StatusBarConfig } from "./layout/StatusRow.js";
@@ -48,6 +45,7 @@ export interface ComposerAreaProps {
input: string;
setInput: (next: string) => void;
busy: boolean;
steerBusy?: boolean;
onSubmit: (raw: string) => Promise<void>;
onHistoryPrev: () => void;
onHistoryNext: () => void;
@@ -72,25 +70,6 @@ export interface ComposerAreaProps {
slashArgSelected: number;
}
// ── History scroll hint ────────────────────────────────────────────
const HistoryHint: React.FC<{ children: React.ReactNode }> = React.memo(({ children }) => {
const pinned = useChatScrollState((s: { pinned: boolean }) => s.pinned);
const { stdout } = useStdout();
if (!pinned) {
const text = "scrolled up — reading history — End / PgDn to return — ↓ to advance one line";
const cols = stdout?.columns ?? 80;
const pad = Math.max(0, cols - stringWidth(text));
return (
<Text color={FG.faint} backgroundColor={SURFACE.bgElev}>
{text + " ".repeat(pad)}
</Text>
);
}
return <>{children}</>;
});
HistoryHint.displayName = "HistoryHint";
// ── Component ─────────────────────────────────────────────────────
export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
@@ -109,6 +88,7 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
input,
setInput,
busy,
steerBusy,
onSubmit,
onHistoryPrev,
onHistoryNext,
@@ -155,6 +135,7 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
onChange={setInput}
onSubmit={onSubmit}
disabled={busy}
steerBusy={steerBusy}
onHistoryPrev={onHistoryPrev}
onHistoryNext={onHistoryNext}
onOpenExternalEditor={onOpenExternalEditor}
@@ -168,7 +149,7 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
</Box>
);
return <HistoryHint>{inputArea}</HistoryHint>;
return inputArea;
},
);
ComposerArea.displayName = "ComposerArea";
+4 -7
View File
@@ -1,4 +1,4 @@
import { Box, Text } from "ink";
import { Box, Text, useStdout } from "ink";
import React, { useMemo, useState } from "react";
import { formatEditBlockSplit } from "../../code/diff-preview.js";
import type { EditBlock } from "../../code/edit-blocks.js";
@@ -7,7 +7,6 @@ import { DenyContextInput } from "./DenyContextInput.js";
import { SplitDiff } from "./SplitDiff.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useKeystroke } from "./keystroke-context.js";
import { useReserveRows, useTotalRows } from "./layout/viewport-budget.js";
export type EditReviewChoice = "apply" | "reject" | "apply-rest-of-turn" | "flip-to-auto";
@@ -20,11 +19,9 @@ const MODAL_OVERHEAD_ROWS = 18;
const MIN_DIFF_ROWS = 8;
export function EditConfirm({ block, onChoose }: EditConfirmProps) {
const rows = useTotalRows();
const allocated = useReserveRows("modal", {
min: MODAL_OVERHEAD_ROWS + MIN_DIFF_ROWS,
max: Math.max(MODAL_OVERHEAD_ROWS + MIN_DIFF_ROWS, rows - 4),
});
const { stdout } = useStdout();
const rows = stdout?.rows ?? 40;
const allocated = Math.max(MODAL_OVERHEAD_ROWS + MIN_DIFF_ROWS, rows - 4);
const budget = Math.max(MIN_DIFF_ROWS, allocated - MODAL_OVERHEAD_ROWS);
const allRows = useMemo(
+16 -5
View File
@@ -5,7 +5,6 @@ import { t } from "../../i18n/index.js";
import { DenyContextInput } from "./DenyContextInput.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { FG } from "./theme/tokens.js";
export type PathConfirmChoice = "run_once" | "always_allow" | "deny";
@@ -16,8 +15,6 @@ export interface PathConfirmProps {
}
export function PathConfirm({ prompt, onChoose }: PathConfirmProps) {
useReserveRows("modal", { min: 8, max: 14 });
const [phase, setPhase] = useState<"pick" | "deny">("pick");
if (phase === "deny") {
@@ -39,12 +36,26 @@ export function PathConfirm({ prompt, onChoose }: PathConfirmProps) {
const path = prompt.subtitle ?? "";
const allowPrefix = String(prompt.data?.prefix ?? "");
// prompt.kind collapses read/write into "path"; recover from data.intent.
const intent = prompt.data?.intent === "write" ? "write" : "read";
const localTitle =
intent === "write" ? t("pathConfirm.promptTitleWrite") : t("pathConfirm.promptTitleRead");
const localActionLabel = (id: string, fallback: string): string => {
if (id === "run_once") {
return intent === "write"
? t("pathConfirm.actionAllowWrite")
: t("pathConfirm.actionAllowRead");
}
if (id === "always_allow") return t("pathConfirm.actionAlwaysAllow", { prefix: allowPrefix });
if (id === "deny") return t("pathConfirm.actionDeny");
return fallback;
};
return (
<ApprovalCard
tone={prompt.tone}
glyph="!"
title={prompt.title}
title={localTitle}
metaRight={t("pathConfirm.awaiting")}
footerHint={t("pathConfirm.pickFooter")}
>
@@ -54,7 +65,7 @@ export function PathConfirm({ prompt, onChoose }: PathConfirmProps) {
<InfoRows path={path} sandboxRoot={prompt.meta?.sandboxRoot} allowPrefix={allowPrefix} />
<SingleSelect
initialValue={prompt.actions[0]?.id ?? "run_once"}
items={prompt.actions.map((a) => ({ value: a.id, label: a.label }))}
items={prompt.actions.map((a) => ({ value: a.id, label: localActionLabel(a.id, a.label) }))}
onSubmit={(v) => {
const action = prompt.actions.find((a) => a.id === v);
if (action?.secondaryInput) {
-4
View File
@@ -7,7 +7,6 @@ import type { PlanStep } from "../../tools/plan.js";
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useReserveRows } from "./layout/viewport-budget.js";
export type CheckpointChoice = "continue" | "revise" | "stop";
@@ -32,9 +31,6 @@ function PlanCheckpointConfirmInner({
completedStepIds,
onChoose,
}: PlanCheckpointConfirmProps) {
const stepRows = steps?.length ?? 0;
useReserveRows("modal", { min: 10, max: Math.max(14, stepRows + 12) });
const label = title ? `${stepId} · ${title}` : stepId;
const counter = total > 0 ? `${completed}/${total}` : "";
const isLast = total > 0 && completed >= total;
+4 -8
View File
@@ -6,7 +6,6 @@ import { PlanStepList } from "./PlanStepList.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useKeystroke } from "./keystroke-context.js";
import { useReserveRows, useTotalRows } from "./layout/viewport-budget.js";
import { MarkdownView } from "./markdown-view.js";
import { extractOpenQuestionsSection } from "./plan-open-questions.js";
import type { KeyEvent } from "./stdin-reader.js";
@@ -31,7 +30,7 @@ const EXPANDED_DETAIL_CHROME_ROWS = 4;
function PlanConfirmInner({ plan, steps, summary, onChoose }: PlanConfirmProps) {
const { stdout } = useStdout();
const totalRows = useTotalRows();
const totalRows = stdout?.rows ?? 40;
const [expanded, setExpanded] = useState(false);
const [detailOffset, setDetailOffset] = useState(0);
const stepRows = steps?.length ?? 0;
@@ -44,12 +43,9 @@ function PlanConfirmInner({ plan, steps, summary, onChoose }: PlanConfirmProps)
);
const oqRows = openQuestions ? Math.min(openQuestions.split("\n").length, 8) : 0;
const modalRows = useReserveRows("modal", {
min: 10,
max: expanded
? Math.max(10, totalRows - EXPANDED_DETAIL_CHROME_ROWS)
: Math.max(16, Math.min(32, (hasSteps ? stepRows + 2 : 2) + oqRows + 14)),
});
const modalRows = expanded
? Math.max(10, totalRows - EXPANDED_DETAIL_CHROME_ROWS)
: Math.max(16, Math.min(32, (hasSteps ? stepRows + 2 : 2) + oqRows + 14));
const detailViewRows = expanded
? Math.max(10, modalRows - EXPANDED_MODAL_OVERHEAD_ROWS)
: Math.max(
+36 -54
View File
@@ -2,7 +2,6 @@ import { Box, Text, useStdout } from "ink";
import React, { useEffect, useRef, useState } from "react";
import { t } from "../../i18n/index.js";
import { useKeystroke } from "./keystroke-context.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { type MultilineKey, lineAndColumn, processMultilineKey } from "./multiline-keys.js";
import {
PASTE_SENTINEL_RANGE,
@@ -44,6 +43,7 @@ export interface PromptInputProps {
onSubmit: (v: string) => void;
disabled?: boolean;
placeholder?: string;
steerBusy?: boolean;
/** ↑/↓ / Ctrl+N hand off here when no in-buffer cursor move applies — parent walks history and swaps `value` via `onChange`. */
onHistoryPrev?: () => void;
onHistoryNext?: () => void;
@@ -64,6 +64,7 @@ export function PromptInput({
onSubmit,
disabled,
placeholder,
steerBusy,
onHistoryPrev,
onHistoryNext,
onOpenExternalEditor,
@@ -72,14 +73,6 @@ export function PromptInput({
mode,
model,
}: 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;
const reserveMax = Math.min(Math.ceil(inputLineCount / 4) * 4 + 3, 24);
useReserveRows("input", { min: 1, max: reserveMax });
const [cursor, setCursor] = useState(value.length);
useEffect(() => {
@@ -126,8 +119,11 @@ export function PromptInput({
setCursor(c + insertion.length);
};
const inputFrozen = disabled && !steerBusy;
const inputActive = !disabled || !!steerBusy;
useKeystroke((ev) => {
if (disabled) return;
if (inputFrozen) return;
if (ev.paste) {
// Bracketed-paste content delivered by the stdin reader.
if (ev.input.length > 0) registerPaste(ev.input);
@@ -184,7 +180,7 @@ export function PromptInput({
if (action.historyHandoff === "prev") onHistoryPrev?.();
if (action.historyHandoff === "next") onHistoryNext?.();
if (action.openExternalEditor) onOpenExternalEditor?.();
}, !disabled);
}, inputActive);
// ── Render ──────────────────────────────────────────────────────
@@ -197,48 +193,20 @@ export function PromptInput({
// Hint avoids literal `/` and `@` glyphs — they render in the same row as
// a just-cleared buffer and read as residual typed input on dim-poor terminals.
const effectivePlaceholder = disabled
? (placeholder ?? t("composer.waitingForResponse"))
: (placeholder ?? t("composer.placeholder"));
const effectivePlaceholder = steerBusy
? t("composer.steerPlaceholder")
: disabled
? (placeholder ?? t("composer.waitingForResponse"))
: (placeholder ?? t("composer.placeholder"));
const lines = value.length > 0 ? value.split("\n") : [""];
const accentColor = disabled ? FG.faint : TONE.brand;
const accentColor = steerBusy ? TONE.brand : disabled ? FG.faint : TONE.brand;
const cursorVisible = true;
const { line: cursorLine, col: cursorCol } = lineAndColumn(value, cursor);
const renderItems = collapseLinesForDisplay(lines, cursorLine);
const showHugeBufferHints = lines.length > 20;
// IME cursor sync. Ink writes a full ANSI frame each render; the
// terminal cursor ends up at the bottom of that frame and IMEs
// (fcitx5 / ibus on Linux, Microsoft Pinyin on Windows) query it for
// candidate-window placement. Without this, popups land at screen
// bottom instead of next to the visual ▌. Issue #1261.
const cursorLineText = lines[cursorLine] ?? "";
const cursorCellsInLine = stringCells(cursorLineText.slice(0, cursorCol), pastesRef.current);
useEffect(() => {
if (!stdout || disabled) return;
const totalRows = stdout.rows;
if (!totalRows || totalRows < 4) return;
const linesBelow = Math.max(0, lines.length - 1 - cursorLine);
const largeHint = showHugeBufferHints ? 1 : 0;
// Inside our bordered Box, below the visual cursor: remaining input
// lines, optional huge-hint, blank marginTop, HintRow, bottom border.
const rowsBelow = linesBelow + largeHint + 3 + rowsAfter;
const targetRow = Math.max(1, totalRows - rowsBelow);
// Column 1-indexed. No left border, paddingX=1, prompt prefix "› " = 2 cells.
const targetCol = 1 + 1 + 2 + cursorCellsInLine;
stdout.write(`\x1b[${targetRow};${targetCol}H`);
}, [
stdout,
disabled,
cursorLine,
cursorCellsInLine,
lines.length,
showHugeBufferHints,
rowsAfter,
]);
return (
<Box flexDirection="row">
<Box width={1} backgroundColor="#0153e5" />
@@ -270,7 +238,7 @@ export function PromptInput({
key={`ln-${i}-text-0`}
line=""
isFirst={true}
isCursorLine={isCursorLine && !disabled}
isCursorLine={isCursorLine && inputActive}
cursorCol={isCursorLine ? cursorCol : null}
cursorVisible={cursorVisible}
showPlaceholder
@@ -281,6 +249,7 @@ export function PromptInput({
accentColor={accentColor}
pastes={pastesRef.current}
disabled={disabled === true}
steerBusy={steerBusy}
/>,
);
firstRowEmitted = true;
@@ -300,7 +269,7 @@ export function PromptInput({
entry={pastesRef.current.get(seg.id)}
pasteId={seg.id}
isFirst={isFirst}
active={cursorOnIt && !disabled}
active={cursorOnIt && inputActive}
visibleCells={visibleCells}
accentColor={accentColor}
/>,
@@ -316,7 +285,7 @@ export function PromptInput({
key={`ln-${i}-text-${segIdx}`}
line={seg.text}
isFirst={isFirst}
isCursorLine={segHasCursor && !disabled}
isCursorLine={segHasCursor && inputActive}
cursorCol={segHasCursor ? cursorCol - seg.startOffset : null}
cursorVisible={cursorVisible}
showPlaceholder={false}
@@ -327,6 +296,7 @@ export function PromptInput({
accentColor={accentColor}
pastes={pastesRef.current}
disabled={disabled === true}
steerBusy={steerBusy}
/>,
);
}
@@ -338,7 +308,7 @@ export function PromptInput({
key={`ln-${i}-empty`}
line=""
isFirst={isFirst}
isCursorLine={isCursorLine && !disabled}
isCursorLine={isCursorLine && inputActive}
cursorCol={isCursorLine ? 0 : null}
cursorVisible={cursorVisible}
showPlaceholder={false}
@@ -349,13 +319,14 @@ export function PromptInput({
accentColor={accentColor}
pastes={pastesRef.current}
disabled={disabled === true}
steerBusy={steerBusy}
/>,
);
}
}
return rows;
})()}
{showHugeBufferHints && !disabled ? (
{showHugeBufferHints && inputActive ? (
<Box>
<Text color={FG.faint}>
{` [${lines.length} lines · PgUp/PgDn jump · Ctrl+U clear · Ctrl+W del word]`}
@@ -371,11 +342,19 @@ export function PromptInput({
</Box>
) : null}
<Box height={1} />
{disabled ? (
{inputFrozen ? (
<Box marginTop={1}>
<Text color={FG.faint}>{" esc to stop"}</Text>
</Box>
) : null}
{steerBusy ? (
<Box marginTop={1} flexDirection="row">
<Text color={TONE.accent}>{" \u23ce "}</Text>
<Text color={FG.faint}>{t("composer.steerHint")}</Text>
<Text color={FG.faint}>{" · "}</Text>
<Text color={FG.faint}>{"esc to stop"}</Text>
</Box>
) : null}
</Box>
</Box>
);
@@ -384,7 +363,7 @@ export function PromptInput({
export function HintRow(): React.ReactElement {
const items: Array<{ key: string; tKey: string }> = [
{ key: "\u23ce", tKey: "composer.hintSend" },
{ key: "\u21e7\u23ce", tKey: "composer.hintNewline" },
{ key: "\u21e7\u23ce / ^J", tKey: "composer.hintNewline" },
{ key: "^U", tKey: "composer.hintClear" },
{ key: "\u2191\u2193", tKey: "composer.hintHistory" },
{ key: "esc", tKey: "composer.hintAbort" },
@@ -487,7 +466,7 @@ function formatChipLabel(entry: PasteEntry | undefined, pasteId: number, budget:
const lines = `${entry.lineCount} line${entry.lineCount === 1 ? "" : "s"}`;
const bytes = formatBytesShort(entry.charCount);
const kind = sniffChipKind(entry.content);
const full = `📋 pasted ${lines} · ${bytes} · ${kind} ^O expand · ⌫ remove`;
const full = `📋 pasted ${lines} · ${bytes} · ${kind} ⌫ remove`;
if (full.length <= Math.max(40, budget)) return full;
const compact = `📋 pasted ${lines} · ${bytes} · ${kind}`;
if (compact.length <= Math.max(30, budget)) return compact;
@@ -527,6 +506,7 @@ interface PromptLineProps {
accentColor: string;
pastes: ReadonlyMap<number, PasteEntry>;
disabled: boolean;
steerBusy?: boolean;
}
function PromptLine({
@@ -543,14 +523,16 @@ function PromptLine({
accentColor,
pastes,
disabled,
steerBusy,
}: PromptLineProps) {
const promptActive = !disabled || !!steerBusy;
if (showPlaceholder) {
return (
<Box>
<Text bold color={accentColor}>
{promptPrefix}
</Text>
{!disabled ? <Text color={accentColor}>{cursorVisible ? "▌" : " "}</Text> : null}
{promptActive ? <Text color={accentColor}>{cursorVisible ? "▌" : " "}</Text> : null}
<Text color={FG.faint}>{placeholderText}</Text>
</Box>
);
+17 -4
View File
@@ -5,7 +5,6 @@ import { t } from "../../i18n/index.js";
import { DenyContextInput } from "./DenyContextInput.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { FG } from "./theme/tokens.js";
export type ShellConfirmChoice = "run_once" | "always_allow" | "deny";
@@ -16,7 +15,6 @@ export interface ShellConfirmProps {
}
export function ShellConfirm({ prompt, onChoose }: ShellConfirmProps) {
useReserveRows("modal", { min: 8, max: 14 });
const [phase, setPhase] = useState<"pick" | "deny">("pick");
if (phase === "deny") {
@@ -36,11 +34,26 @@ export function ShellConfirm({ prompt, onChoose }: ShellConfirmProps) {
);
}
// toApprovalPrompt mints English labels for the ACP surface; the unified
// prompt.kind collapses foreground + background into "shell", so we recover
// the split from meta.wait (only set on background prompts).
const isBackground = prompt.meta?.wait !== undefined;
const prefix = String(prompt.data?.prefix ?? "");
const localTitle = isBackground
? t("shellConfirm.promptTitleRunBackground")
: t("shellConfirm.promptTitleRunCommand");
const localActionLabel = (id: string, fallback: string): string => {
if (id === "run_once") return t("shellConfirm.actionRunOnce");
if (id === "always_allow") return t("shellConfirm.actionAlwaysAllow", { prefix });
if (id === "deny") return t("shellConfirm.actionDeny");
return fallback;
};
return (
<ApprovalCard
tone={prompt.tone}
glyph="?"
title={prompt.title}
title={localTitle}
metaRight={t("shellConfirm.awaiting")}
footerHint={t("shellConfirm.pickFooter")}
>
@@ -57,7 +70,7 @@ export function ShellConfirm({ prompt, onChoose }: ShellConfirmProps) {
<InfoRows meta={prompt.meta} />
<SingleSelect
initialValue={prompt.actions[0]?.id ?? "run_once"}
items={prompt.actions.map((a) => ({ value: a.id, label: a.label }))}
items={prompt.actions.map((a) => ({ value: a.id, label: localActionLabel(a.id, a.label) }))}
onSubmit={(v) => {
const action = prompt.actions.find((a) => a.id === v);
if (action?.secondaryInput) {
+2
View File
@@ -20,6 +20,8 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
items: [
{ keys: "Enter", descKey: "shortcutsHelp.descEnter" },
{ keys: "Shift+Enter", descKey: "shortcutsHelp.descShiftEnter" },
{ keys: "Ctrl+Enter", descKey: "shortcutsHelp.descCtrlEnter" },
{ keys: "Ctrl+J", descKey: "shortcutsHelp.descCtrlJ" },
{ keys: "Ctrl+U", descKey: "shortcutsHelp.descCtrlU" },
{ keys: "Ctrl+W", descKey: "shortcutsHelp.descCtrlW" },
{ keys: "Ctrl+P", descKey: "shortcutsHelp.descCtrlP" },
+1 -1
View File
@@ -1,6 +1,5 @@
import { Box, Text, useStdout } from "ink";
import React from "react";
import { clipToCells } from "../../../frame/width.js";
import { t } from "../../../i18n/index.js";
import { Card } from "../primitives/Card.js";
import { CardHeader, type MetaItem } from "../primitives/CardHeader.js";
@@ -9,6 +8,7 @@ import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pil
import { Spinner } from "../primitives/Spinner.js";
import type { ReasoningCard as ReasoningCardData } from "../state/cards.js";
import { VerboseContext } from "../state/verbose-context.js";
import { clipToCells } from "../text-width.js";
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
import { useIncrementalWrap } from "./useIncrementalWrap.js";
+1 -7
View File
@@ -1,16 +1,15 @@
import { Box, Text, useStdout } from "ink";
import React, { useContext } from "react";
import { clipToCells } from "../../../frame/width.js";
import { t } from "../../../i18n/index.js";
import { countTokensBounded } 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";
import { CardHeader } from "../primitives/CardHeader.js";
import { PILL_MODEL, Pill, modelBadgeFor } from "../primitives/Pill.js";
import { Spinner } from "../primitives/Spinner.js";
import type { StreamingCard as StreamingCardData } from "../state/cards.js";
import { clipToCells } from "../text-width.js";
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
import { useSlowTick } from "../ticker.js";
import { useIncrementalWrap } from "./useIncrementalWrap.js";
@@ -99,11 +98,6 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
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: 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();
+1 -1
View File
@@ -1,6 +1,5 @@
import { Box, Text, useStdout } from "ink";
import React from "react";
import { clipToCells } from "../../../frame/width.js";
import { t } from "../../../i18n/index.js";
import { Markdown } from "../markdown.js";
import { Card } from "../primitives/Card.js";
@@ -9,6 +8,7 @@ import { Spinner } from "../primitives/Spinner.js";
import type { ToolCard as ToolCardData } from "../state/cards.js";
import { useIsInflight } from "../state/inflight-context.js";
import { VerboseContext } from "../state/verbose-context.js";
import { clipToCells } from "../text-width.js";
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
const READ_TAIL = 2;
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { wrapToCells } from "../../../frame/width.js";
import { wrapToCells } from "../text-width.js";
export interface WrapCache {
text: string;
-195
View File
@@ -1,195 +0,0 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value
import React, { useMemo, useState } from "react";
import { clipToCells } from "../../../frame/width.js";
import { t } from "../../../i18n/index.js";
import { writeClipboard } from "../clipboard.js";
import { useKeystroke } from "../keystroke-context.js";
import type { Card } from "../state/cards.js";
import { FG, TONE } from "../theme/tokens.js";
import { type SnapshotLine, buildSnapshot, isYankable, yankRange } from "./snapshot.js";
export interface CopyModeProps {
cards: ReadonlyArray<Card>;
onClose: (yanked: { size: number; osc52: boolean; filePath: string | null } | null) => void;
}
const CHROME_ROWS = 3;
export function CopyMode({ cards, onClose }: CopyModeProps): React.ReactElement {
const snapshot = useMemo(() => buildSnapshot(cards), [cards]);
const { stdout } = useStdout();
const termRows = stdout?.rows ?? 30;
const termCols = stdout?.columns ?? 80;
const bodyRows = Math.max(4, termRows - CHROME_ROWS);
const lastYankableIdx = findLastYankable(snapshot);
const initialCursor = findFirstYankable(snapshot);
const [cursor, setCursor] = useState(initialCursor);
const [anchor, setAnchor] = useState<number | null>(null);
const [status, setStatus] = useState<string | null>(null);
const stepDown = (i: number) => stepBy(snapshot, i, +1);
const stepUp = (i: number) => stepBy(snapshot, i, -1);
useKeystroke((ev) => {
if (ev.escape || (ev.input === "q" && !ev.ctrl && !ev.meta)) return onClose(null);
if (ev.input === "j" || ev.downArrow) return setCursor(stepDown(cursor));
if (ev.input === "k" || ev.upArrow) return setCursor(stepUp(cursor));
if (ev.pageDown) return setCursor(scrollBy(snapshot, cursor, bodyRows));
if (ev.pageUp) return setCursor(scrollBy(snapshot, cursor, -bodyRows));
if (ev.input === "g") return setCursor(initialCursor);
if (ev.input === "G") return setCursor(lastYankableIdx);
if (ev.input === "v" || ev.input === "V") {
setAnchor((a) => (a === null ? cursor : null));
return;
}
if (ev.input === "y" || ev.return) {
const from = anchor ?? cursor;
const to = cursor;
const text = yankRange(snapshot, from, to).trim();
if (text.length === 0) {
setStatus(t("copyMode.statusEmpty"));
return;
}
const w = writeClipboard(text);
setStatus(t("copyMode.statusYanked", { size: text.length, osc52: w.osc52 ? "y" : "n" }));
setTimeout(() => onClose(w), 600);
}
});
const window = computeWindow(snapshot, cursor, bodyRows);
const selRange =
anchor === null ? null : ([Math.min(anchor, cursor), Math.max(anchor, cursor)] as const);
const totalY = countYankable(snapshot);
const cursorY = countYankableUntil(snapshot, cursor);
return (
<Box flexDirection="column">
<Box>
<Text color={TONE.brand} bold>
{t("copyMode.title")}
</Text>
<Text color={FG.faint}>{` ${t("copyMode.help")}`}</Text>
</Box>
<Box flexDirection="column">
{snapshot.length === 0 ? (
<Text color={FG.faint}>{t("copyMode.empty")}</Text>
) : (
window.lines.map((line, i) => {
const idx = window.start + i;
return (
<CopyLine
key={`${line.cardId}-${idx}`}
line={line}
cols={termCols}
isCursor={idx === cursor}
inSelection={selRange !== null && idx >= selRange[0] && idx <= selRange[1]}
/>
);
})
)}
</Box>
<Box>
<Text color={FG.meta}>
{t("copyMode.statusBar", {
cur: cursorY > 0 ? cursorY : 1,
total: Math.max(1, totalY),
sel: anchor === null ? "—" : String(rangeYankable(snapshot, anchor, cursor)),
})}
</Text>
{status ? <Text color={TONE.ok}>{` ${status}`}</Text> : null}
</Box>
</Box>
);
}
function CopyLine({
line,
cols,
isCursor,
inSelection,
}: {
line: SnapshotLine;
cols: number;
isCursor: boolean;
inSelection: boolean;
}): React.ReactElement {
const marker = isCursor ? "▸ " : " ";
const room = Math.max(1, cols - 2);
const display = line.kind === "blank" ? "" : clipToCells(line.text, room);
if (line.kind === "header") {
return (
<Box>
<Text color={isCursor ? TONE.brand : FG.faint}>{marker}</Text>
<Text color={FG.meta}>{display}</Text>
</Box>
);
}
const color = isCursor ? TONE.brand : FG.body;
return (
<Box>
<Text color={isCursor ? TONE.brand : FG.faint}>{marker}</Text>
<Text color={color} inverse={inSelection}>
{display.length === 0 ? " " : display}
</Text>
</Box>
);
}
function findFirstYankable(snapshot: ReadonlyArray<SnapshotLine>): number {
for (let i = 0; i < snapshot.length; i++) if (isYankable(snapshot[i])) return i;
return 0;
}
function findLastYankable(snapshot: ReadonlyArray<SnapshotLine>): number {
for (let i = snapshot.length - 1; i >= 0; i--) if (isYankable(snapshot[i])) return i;
return Math.max(0, snapshot.length - 1);
}
function stepBy(snapshot: ReadonlyArray<SnapshotLine>, from: number, dir: 1 | -1): number {
const last = snapshot.length - 1;
let i = Math.max(0, Math.min(last, from + dir));
while (i > 0 && i < last && snapshot[i]?.kind === "header") i += dir;
if (i < 0) return 0;
if (i > last) return last;
return i;
}
function scrollBy(snapshot: ReadonlyArray<SnapshotLine>, from: number, delta: number): number {
const last = snapshot.length - 1;
return Math.max(0, Math.min(last, from + delta));
}
function computeWindow(
snapshot: ReadonlyArray<SnapshotLine>,
cursor: number,
rows: number,
): { start: number; lines: SnapshotLine[] } {
if (snapshot.length <= rows) return { start: 0, lines: snapshot.slice() };
const half = Math.floor(rows / 2);
let start = Math.max(0, cursor - half);
if (start + rows > snapshot.length) start = snapshot.length - rows;
return { start, lines: snapshot.slice(start, start + rows) };
}
function countYankable(snapshot: ReadonlyArray<SnapshotLine>): number {
let n = 0;
for (const line of snapshot) if (isYankable(line)) n++;
return n;
}
function countYankableUntil(snapshot: ReadonlyArray<SnapshotLine>, idx: number): number {
let n = 0;
for (let i = 0; i <= Math.min(idx, snapshot.length - 1); i++) if (isYankable(snapshot[i])) n++;
return n;
}
function rangeYankable(snapshot: ReadonlyArray<SnapshotLine>, a: number, b: number): number {
const lo = Math.min(a, b);
const hi = Math.max(a, b);
let n = 0;
for (let i = lo; i <= hi; i++) if (isYankable(snapshot[i])) n++;
return n;
}
-61
View File
@@ -1,61 +0,0 @@
import { t } from "../../../i18n/index.js";
import type { Card } from "../state/cards.js";
export type SnapshotLineKind = "header" | "text" | "blank";
export interface SnapshotLine {
readonly cardId: string;
readonly kind: SnapshotLineKind;
readonly role: "user" | "assistant" | "reasoning";
readonly text: string;
}
export function buildSnapshot(cards: ReadonlyArray<Card>): SnapshotLine[] {
const out: SnapshotLine[] = [];
for (const card of cards) {
if (card.kind === "user") {
pushCard(out, card.id, "user", t("copyMode.labelUser"), card.text);
} else if (card.kind === "streaming") {
pushCard(out, card.id, "assistant", t("copyMode.labelAssistant"), card.text);
} else if (card.kind === "reasoning") {
pushCard(out, card.id, "reasoning", t("copyMode.labelReasoning"), card.text);
}
}
return out;
}
function pushCard(
out: SnapshotLine[],
cardId: string,
role: SnapshotLine["role"],
label: string,
body: string,
): void {
if (out.length > 0) out.push({ cardId, kind: "blank", role, text: "" });
out.push({ cardId, kind: "header", role, text: `─── ${label} ───` });
const lines = body.length === 0 ? [""] : body.split("\n");
for (const line of lines) out.push({ cardId, kind: "text", role, text: line });
}
export function yankRange(
snapshot: ReadonlyArray<SnapshotLine>,
fromIdx: number,
toIdx: number,
): string {
const lo = Math.min(fromIdx, toIdx);
const hi = Math.max(fromIdx, toIdx);
const picks: string[] = [];
for (let i = lo; i <= hi; i++) {
const line = snapshot[i];
if (!line) continue;
if (line.kind === "header") continue;
picks.push(line.text);
}
while (picks.length > 0 && picks[picks.length - 1] === "") picks.pop();
while (picks.length > 0 && picks[0] === "") picks.shift();
return picks.join("\n");
}
export function isYankable(line: SnapshotLine | undefined): boolean {
return !!line && line.kind !== "header";
}
+2
View File
@@ -32,6 +32,8 @@ export function loopEventToDashboard(
return { kind: "error", id, text: ev.content };
case "status":
return { kind: "status", text: ev.content };
case "steer":
return { kind: "user", id, text: ev.content };
default:
return null;
}
-18
View File
@@ -1,18 +0,0 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { type Frame, frameToAnsi } from "../../frame/index.js";
/** Frame → JSX. One Box per row. */
export function renderFrame(f: Frame, keyPrefix: string): React.ReactElement {
return (
<>
{f.rows.map((row, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: row index is the row's identity
<Box key={`${keyPrefix}/${i}`} height={1} flexShrink={0}>
<Text>{frameToAnsi({ width: f.width, rows: [row] })}</Text>
</Box>
))}
</>
);
}
-238
View File
@@ -1,238 +0,0 @@
import { Box, type DOMElement, Text, useBoxMetrics, useStdout } from "ink";
import React, { useEffect, useMemo, useRef } from "react";
import stringWidth from "string-width";
import { t } from "../../../i18n/index.js";
import { CardRenderer } from "../cards/CardRenderer.js";
import type { Card } from "../state/cards.js";
import { useChatScrollActions, useChatScrollState } from "../state/chat-scroll-provider.js";
import { useAgentState } from "../state/provider.js";
import { FG, SURFACE, TONE } from "../theme/tokens.js";
/** Buffer of rows kept rendered on each side of the viewport so a single scroll
* step doesn't reveal an unmeasured card. Larger = smoother but renders more. */
export const VISIBLE_BUFFER_ROWS = 30;
export type CardStreamItem<T> =
| { kind: "spacer"; rows: number; key: string }
| { kind: "card"; card: T };
/** Decide which cards render live vs collapse into a spacer, given the cached
* heights and the current viewport position. Window is quantized to
* VISIBLE_BUFFER_ROWS buckets so a single-row scrollRows / outerHeight wiggle
* doesn't toggle a boundary card and re-trigger inner.height oscillation
* (root cause of issues #549 / #700). */
export function computeCardStreamItems<T extends { id: string }>(
cards: readonly T[],
cardHeights: ReadonlyMap<string, number>,
scrollRows: number,
outerHeight: number,
): CardStreamItem<T>[] {
const bucket = Math.floor(scrollRows / VISIBLE_BUFFER_ROWS) * VISIBLE_BUFFER_ROWS;
const winStart = Math.max(0, bucket - VISIBLE_BUFFER_ROWS);
const winEnd = bucket + outerHeight + VISIBLE_BUFFER_ROWS * 2;
const out: CardStreamItem<T>[] = [];
let cursor = 0;
let pendingSpacer = 0;
let spacerKey = 0;
for (const card of cards) {
const h = cardHeights.get(card.id);
const cardEnd = cursor + (h ?? 0);
const live = h === undefined || (cardEnd >= winStart && cursor <= winEnd);
if (live) {
if (pendingSpacer > 0) {
out.push({ kind: "spacer", rows: pendingSpacer, key: `sp-${spacerKey++}` });
pendingSpacer = 0;
}
out.push({ kind: "card", card });
} else {
pendingSpacer += h ?? 0;
}
cursor = cardEnd;
}
if (pendingSpacer > 0) {
out.push({ kind: "spacer", rows: pendingSpacer, key: `sp-${spacerKey}` });
}
return out;
}
/**
* Row-precision virtual scroll with card-level virtualization.
*
* outer Box clips with overflow="hidden"; inner Box holds visible cards
* plus spacer Boxes for off-screen ranges and slides up via negative
* marginTop. Off-screen cards are replaced by a single spacer Box of the
* cumulative height — Yoga skips them entirely on every re-layout.
*
* Heights are populated lazily: any card whose height isn't cached yet
* is rendered live (so it can be measured), then collapses into the
* spacer once outside the viewport. A streaming card that grows on every
* delta keeps its height fresh through the same measurement path.
*/
export function CardStream({
suppressLive = false,
}: {
suppressLive?: boolean;
}): React.ReactElement {
const cards = useAgentState((s) => s.cards);
const scrollRows = useChatScrollState((s) => s.scrollRows);
const cardHeights = useChatScrollState((s) => s.cardHeights);
const { setMaxScroll, setCardHeight, pruneCardHeights } = useChatScrollActions();
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(() => {
setMaxScroll(maxScroll);
}, [maxScroll, setMaxScroll]);
// Drop heights for cards no longer in the list (resumed sessions, /clear, etc).
useEffect(() => {
const live = new Set<string>();
for (const c of cards) live.add(c.id);
pruneCardHeights(live);
}, [cards, pruneCardHeights]);
let visible = cards;
if (suppressLive && cards.length > 0 && !isFullySettled(cards[cards.length - 1]!)) {
visible = cards.slice(0, -1);
}
const items = useMemo(
() => computeCardStreamItems(visible, cardHeights, scrollRows, outer.height),
[visible, cardHeights, scrollRows, outer.height],
);
return (
<>
{/* Always reserve the row — making it conditional ties outer.height to scrollRows and closes a setState loop with pinned mode. */}
<Box height={1} flexShrink={0}>
{scrollRows > 0 ? <ScrollIndicator scrollRows={scrollRows} maxScroll={maxScroll} /> : null}
</Box>
<Box ref={outerRef} flexDirection="column" flexGrow={1} overflow="hidden">
<Box ref={innerRef} flexDirection="column" marginTop={-scrollRows} flexShrink={0}>
{items.map((item) =>
item.kind === "spacer" ? (
<Box key={item.key} height={item.rows} flexShrink={0} />
) : (
<MeasuredCard key={item.card.id} card={item.card} report={setCardHeight} />
),
)}
</Box>
</Box>
</>
);
}
/** Thin wrapper that captures a card's row height on every render and reports
* it to the scroll store. Wrapping in React.memo would defeat the purpose —
* we *want* the effect to re-run when the streaming card grows.
*
* Monotonic height lock (#549 / #700): for unsettled (streaming/reasoning)
* cards, height is only reported when it INCREASES. Yoga can emit intermediate
* shrink measurements during re-layout that would otherwise cause the virtual
* window to oscillate. Settled cards always report the exact final height. */
function MeasuredCard({
card,
report,
}: { card: Card; report: (id: string, rows: number) => void }): React.ReactElement {
const ref = useRef<DOMElement>(null!);
const m = useBoxMetrics(ref);
const lastReportedRef = useRef<number>(0);
const settled = isCardSettled(card);
useEffect(() => {
const h = m.height;
if (h <= 0) return;
// Dedup: skip if height hasn't changed since last report.
if (h === lastReportedRef.current) return;
// Monotonic lock: for unsettled cards, only report growth.
// Yoga may emit transient shrink values during streaming re-layout
// that would otherwise feed back into scroll position oscillation.
if (!settled && h < lastReportedRef.current) return;
lastReportedRef.current = h;
report(card.id, h);
}, [card.id, m.height, report, settled]);
return (
<Box ref={ref} flexDirection="column" flexShrink={0}>
<CardRenderer card={card} />
</Box>
);
}
/** Position indicator in the row above the viewport. Briefly highlights on every
* scroll tick (scrollVersion bump) so the user gets visual confirmation that
* the wheel/arrow registered, even before the new frame paints. */
function ScrollIndicator({
scrollRows,
maxScroll,
}: { scrollRows: number; maxScroll: number }): React.ReactElement {
const version = useChatScrollState((s) => s.scrollVersion);
const [hot, setHot] = React.useState(false);
React.useEffect(() => {
if (version === 0) return;
setHot(true);
const id = setTimeout(() => setHot(false), 220);
return () => clearTimeout(id);
}, [version]);
const remaining = Math.max(0, maxScroll - scrollRows);
const above =
scrollRows === 1
? t("cardStream.scrollAbove", { scroll: scrollRows, max: maxScroll })
: t("cardStream.scrollAbovePlural", { scroll: scrollRows, max: maxScroll });
const more = remaining > 0 ? t("cardStream.scrollMore", { remaining }) : "";
// `/copy` lives next to the scroll keys so users see the existing copy-mode
// entry without having to dig through /help. Padded to full terminal width
// so the background covers the whole row, not just up to the last char.
const text = `${above}${more}${t("cardStream.scrollPgUp")}${t("cardStream.scrollCopy")}`;
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
const pad = Math.max(0, cols - stringWidth(text));
return (
<Text color={hot ? TONE.accent : FG.faint} backgroundColor={SURFACE.bgElev}>
{text + " ".repeat(pad)}
</Text>
);
}
function isFullySettled(card: Card): boolean {
switch (card.kind) {
case "streaming":
case "tool":
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;
}
}
/** True when a card's content is final — no more streaming deltas expected.
* Drives the monotonic height lock in MeasuredCard: settled cards always
* report their exact height; unsettled cards only report growth.
*
* If a future card kind legitimately shrinks in height while still in-flight,
* add an explicit entry here — otherwise the monotonic lock won't catch it
* and default:true will report the stale larger height. */
function isCardSettled(card: Card): boolean {
switch (card.kind) {
case "reasoning":
return !card.streaming || !!card.aborted;
case "streaming":
return card.done || !!card.aborted;
case "tool":
return card.done || !!card.aborted;
case "task":
case "subagent":
return card.status !== "running";
default:
return true;
}
}
@@ -1,35 +0,0 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for fragments
import React from "react";
import stringWidth from "string-width";
import { t } from "../../../i18n/index.js";
import { useChatScrollState } from "../state/chat-scroll-provider.js";
import { FG } from "../theme/tokens.js";
/**
* Renders either the input area (pinned) or the "reading history" hint
* (scrolled up). Reads `pinned` from the chat-scroll store directly so
* AppInner doesn't subscribe; toggling pinned only re-renders this leaf.
*/
export function InputAreaWithHistoryHint({
inputArea,
}: {
inputArea: React.ReactNode;
}): React.ReactElement {
const pinned = useChatScrollState((s) => s.pinned);
const { stdout } = useStdout();
if (!pinned) {
const text = t("app.historyScrollHint");
const cols = stdout?.columns ?? 80;
const pad = Math.max(0, cols - stringWidth(text));
return (
<Box flexDirection="row">
<Box width={1} backgroundColor="#0153e5" />
<Box flexGrow={1} backgroundColor="#1e1e1e">
<Text color={FG.faint}>{text + " ".repeat(pad)}</Text>
</Box>
</Box>
);
}
return <>{inputArea}</>;
}
+57
View File
@@ -0,0 +1,57 @@
import { Box, Static } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX
import React, { useMemo } from "react";
import { CardRenderer } from "../cards/CardRenderer.js";
import type { Card } from "../state/cards.js";
import { useAgentState } from "../state/provider.js";
export function StaticCardStream({
suppressLive = false,
}: {
suppressLive?: boolean;
}): React.ReactElement {
const cards = useAgentState((s) => s.cards);
const { settled, live } = useMemo(() => partition(cards), [cards]);
const visibleLive = suppressLive && live.length > 0 ? live.slice(0, -1) : live;
return (
<>
<Static items={settled}>
{(card) => (
<Box key={card.id} flexDirection="column" flexShrink={0}>
<CardRenderer card={card} />
</Box>
)}
</Static>
<Box flexDirection="column" flexShrink={0}>
{visibleLive.map((card) => (
<Box key={card.id} flexDirection="column" flexShrink={0}>
<CardRenderer card={card} />
</Box>
))}
</Box>
</>
);
}
function partition(cards: readonly Card[]): { settled: Card[]; live: Card[] } {
const firstUnsettled = cards.findIndex((c) => !isFullySettled(c));
if (firstUnsettled === -1) return { settled: [...cards], live: [] };
return { settled: cards.slice(0, firstUnsettled), live: cards.slice(firstUnsettled) };
}
function isFullySettled(card: Card): boolean {
switch (card.kind) {
case "streaming":
case "tool":
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;
}
}
-160
View File
@@ -1,160 +0,0 @@
/** Single allocator for vertical viewport rows; consumers claim per-zone via useReserveRows. */
import { useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { createContext, useContext, useEffect, useMemo, useReducer } from "react";
export type ZoneId = "modal" | "status" | "input" | "stream" | "safety";
/** Higher number = claims rows first. */
const ZONE_PRIORITY: Record<ZoneId, number> = {
modal: 100,
status: 60,
input: 50,
stream: 10,
safety: 5,
};
export interface ClaimSpec {
/** Smallest acceptable allocation. May exceed total rows on tiny terminals. */
min: number;
/** Hard ceiling. `Number.POSITIVE_INFINITY` = "soak whatever's left". */
max: number;
}
interface InternalClaim extends ClaimSpec {
zone: ZoneId;
priority: number;
}
/** Pure allocator — used by the provider and tested in isolation. */
export function allocateRows(
claims: ReadonlyArray<InternalClaim>,
totalRows: number,
): ReadonlyMap<ZoneId, number> {
const sorted = [...claims].sort((a, b) => b.priority - a.priority);
const out = new Map<ZoneId, number>();
let remaining = Math.max(0, totalRows);
for (const c of sorted) {
const want = Math.min(c.max, Math.max(c.min, remaining));
out.set(c.zone, want);
remaining = Math.max(0, remaining - want);
}
return out;
}
interface BudgetState {
/** Active claims keyed by zone — one consumer per zone. */
claims: ReadonlyMap<ZoneId, ClaimSpec>;
totalRows: number;
}
type BudgetAction =
| { type: "claim"; zone: ZoneId; spec: ClaimSpec }
| { type: "release"; zone: ZoneId }
| { type: "resize"; rows: number };
function reducer(state: BudgetState, action: BudgetAction): BudgetState {
switch (action.type) {
case "claim": {
const next = new Map(state.claims);
next.set(action.zone, action.spec);
return { ...state, claims: next };
}
case "release": {
if (!state.claims.has(action.zone)) return state;
const next = new Map(state.claims);
next.delete(action.zone);
return { ...state, claims: next };
}
case "resize":
if (action.rows === state.totalRows) return state;
return { ...state, totalRows: action.rows };
}
}
interface BudgetContextValue {
totalRows: number;
allocations: ReadonlyMap<ZoneId, number>;
claims: ReadonlyMap<ZoneId, ClaimSpec>;
dispatch: React.Dispatch<BudgetAction>;
}
const BudgetContext = createContext<BudgetContextValue | null>(null);
export interface ViewportBudgetProviderProps {
children: React.ReactNode;
/** Test seam — bypasses useStdout. */
initialRows?: number;
}
export function ViewportBudgetProvider({
children,
initialRows,
}: ViewportBudgetProviderProps): React.ReactElement {
const { stdout } = useStdout();
const [state, dispatch] = useReducer(reducer, undefined, () => ({
claims: new Map<ZoneId, ClaimSpec>(),
totalRows: initialRows ?? stdout?.rows ?? 40,
}));
// Single resize listener — children read totalRows from context.
useEffect(() => {
if (initialRows !== undefined) return undefined;
if (!stdout) return undefined;
const onResize = () => dispatch({ type: "resize", rows: stdout.rows ?? 40 });
onResize();
stdout.on("resize", onResize);
return () => {
stdout.off("resize", onResize);
};
}, [stdout, initialRows]);
const allocations = useMemo(() => {
const list: InternalClaim[] = [];
for (const [zone, spec] of state.claims) {
list.push({ zone, priority: ZONE_PRIORITY[zone], ...spec });
}
return allocateRows(list, state.totalRows);
}, [state.claims, state.totalRows]);
const value = useMemo<BudgetContextValue>(
() => ({
totalRows: state.totalRows,
allocations,
claims: state.claims,
dispatch,
}),
[state.totalRows, allocations, state.claims],
);
return <BudgetContext.Provider value={value}>{children}</BudgetContext.Provider>;
}
/** Returns actual allocation; falls back to spec.max when no provider is mounted. */
export function useReserveRows(zone: ZoneId, spec: ClaimSpec): number {
const ctx = useContext(BudgetContext);
// Deps key off dispatch (stable) + primitives — whole ctx changes every claim and would loop.
const dispatch = ctx?.dispatch;
useEffect(() => {
if (!dispatch) return undefined;
dispatch({ type: "claim", zone, spec: { min: spec.min, max: spec.max } });
return () => {
dispatch({ type: "release", zone });
};
}, [dispatch, zone, spec.min, spec.max]);
if (!ctx) return Number.isFinite(spec.max) ? spec.max : 40;
const allocated = ctx.allocations.get(zone);
if (allocated !== undefined) return allocated;
// Optimistic max for pre-effect first render.
return Number.isFinite(spec.max) ? spec.max : ctx.totalRows;
}
/** Total terminal rows from the provider; falls back to useStdout if unmounted. */
export function useTotalRows(): number {
const ctx = useContext(BudgetContext);
const { stdout } = useStdout();
return ctx?.totalRows ?? stdout?.rows ?? 40;
}
+1 -1
View File
@@ -5,8 +5,8 @@ import { Box, Text, Transform, useStdout } from "ink";
import { type Token, type Tokens, marked } from "marked";
import React from "react";
import stringWidth from "string-width";
import { wrapToCells } from "../../frame/width.js";
import { decodeHtmlEntities } from "./html-entities.js";
import { wrapToCells } from "./text-width.js";
import { FG, SURFACE, TONE } from "./theme/tokens.js";
/** Left margin consumed by card outer marginLeft + body paddingLeft + safety. */
+10 -35
View File
@@ -1,53 +1,28 @@
// Mouse-protocol selection for the TUI. Reasonix only reacts to the wheel —
// clicks and drags are not handled — so the goal is "wheel works in-app,
// native click/drag/right-click stays untouched".
//
// `?1007h` (DECSET alternate scroll) does exactly that: the terminal
// translates wheel events into up/down (or PgUp/PgDn) key sequences in the
// alt screen, leaving mouse buttons native. Works on iTerm2, GNOME
// Terminal, kitty, Alacritty, recent xterm.
//
// Windows Terminal is the odd one out: it advertises `?1007h` but doesn't
// reliably translate wheel events to keys, so reasonix never sees the
// wheel. The old default `?1000h+?1006h` (full SGR mouse capture) DID work
// for the wheel on WT, at the cost of breaking native text selection /
// right-click — which is what got us into #1337 / #677 / #1419 in the
// first place. On Windows the proven wheel-working mode is still SGR, so
// platform-detect and pick the right one rather than ship a regression
// either way (#1456-followup: WT users lost wheel scroll after the
// alternate-scroll switch).
//
// Escape hatch: `REASONIX_MOUSE_MODE=sgr|alternate-scroll|off` overrides
// the platform default for users on terminals where the auto-pick is
// wrong.
// Reasonix is append-only now: the terminal owns scrollback, copy, and the
// mouse wheel. We never want to capture the wheel, and on startup we emit
// disables for every common mouse-capture mode so stale state from a prior
// crashed TUI in the same terminal can't keep eating wheel events. The
// REASONIX_MOUSE_MODE env var stays as an escape hatch.
type Mode = "alternate-scroll" | "sgr" | "off";
function platformDefault(): Mode {
// Windows Terminal / conhost / ConEmu / Cmder all honor SGR mouse capture
// but not alternate-scroll. The handful of Linux/macOS terminals that
// don't honor `?1007h` (notably macOS Terminal.app) fall back to native
// terminal scrollback, which is acceptable; broken in-app wheel for WT
// users is not.
return process.platform === "win32" ? "sgr" : "alternate-scroll";
}
function readMode(): Mode {
const raw = (process.env.REASONIX_MOUSE_MODE ?? "").toLowerCase();
if (raw === "sgr") return "sgr";
if (raw === "alternate-scroll") return "alternate-scroll";
if (raw === "off") return "off";
return platformDefault();
return "off";
}
const RESET_ALL = "\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1007l\u001b[?1015l";
const SEQUENCES: Record<Mode, { enable: string; disable: string }> = {
"alternate-scroll": { enable: "\u001b[?1007h", disable: "\u001b[?1007l" },
sgr: { enable: "\u001b[?1000h\u001b[?1006h", disable: "\u001b[?1006l\u001b[?1000l" },
off: { enable: "", disable: "" },
off: { enable: RESET_ALL, disable: "" },
};
let active = false;
let activeMode: Mode = "alternate-scroll";
let activeMode: Mode = "off";
export function enableMouseMode(): void {
if (active) return;
+2 -1
View File
@@ -187,7 +187,8 @@ export function processMultilineKey(
}
if (key.return) {
if (key.shift || key.meta) return insertAt(value, cursor, "\n");
// Shift+Enter / Ctrl+Enter / Alt+Enter → 换行
if (key.shift || key.ctrl || key.meta) return insertAt(value, cursor, "\n");
// Bash-style line continuation: trailing '\' + Enter (only when the
// cursor sits at end-of-buffer, so a stray '\' mid-line doesn't
// trigger it).
+3 -8
View File
@@ -136,11 +136,6 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
group: "info",
summary: "keyboard + mouse + copy/paste reference",
},
{
cmd: "copy",
group: "chat",
summary: "vim/tmux-style copy mode — j/k navigate, v select, y yank to clipboard",
},
{
cmd: "feedback",
group: "info",
@@ -337,10 +332,10 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
{
cmd: "search-engine",
group: "advanced",
argsHint: "<mojeek|searxng|metaso|tavily|perplexity|exa> [<key>]",
argsHint: "<bing|searxng|metaso|tavily|perplexity|exa> [<key>]",
summary:
"switch web search backend — mojeek (default, no deps), searxng (self-hosted), metaso (free 100/d), tavily (free 1000/mo), perplexity (AI-native), or exa (AI-native). Provider with no key prompts inline config.",
argCompleter: ["mojeek", "searxng", "metaso", "tavily", "perplexity", "exa"],
"switch web search backend — bing (default, works from CN without proxy), searxng (self-hosted), metaso (free 100/d), tavily (free 1000/mo), perplexity (AI-native), or exa (AI-native). Provider with no key prompts inline config.",
argCompleter: ["bing", "searxng", "metaso", "tavily", "perplexity", "exa"],
aliases: ["se"],
},
{
+1 -4
View File
@@ -1,4 +1,4 @@
import { wrapToCells } from "@/frame/width.js";
import { wrapToCells } from "@/cli/ui/text-width.js";
import { t, tObj } from "@/i18n/index.js";
import { VERSION } from "@/version.js";
import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js";
@@ -156,8 +156,6 @@ const keys: SlashHandler = (_args, _loop, ctx) => {
return {};
};
const copy: SlashHandler = () => ({ openCopyMode: true });
const about: SlashHandler = () => {
const lines = [
t("handlers.basic.aboutHeader", { version: VERSION }),
@@ -176,6 +174,5 @@ export const handlers: Record<string, SlashHandler> = {
retry,
loop,
keys,
copy,
about,
};
+56
View File
@@ -1,6 +1,7 @@
import { basename } from "node:path";
import { listPlanArchives, loadPlanState, relativeTime } from "@/code/plan-store.js";
import { t } from "@/i18n/index.js";
import type { StepCompletion, StepEvidence } from "@/tools/plan.js";
import type { SlashHandler } from "../dispatch.js";
const plans: SlashHandler = (args, loop, ctx) => {
@@ -28,6 +29,11 @@ const plans: SlashHandler = (args, loop, ctx) => {
when,
}),
);
const lifecycle = ctx.getEngineeringLifecycleSnapshot?.();
if (lifecycle?.mode !== "off" && lifecycle?.mutatedSinceLastStep) {
lines.push(t("handlers.plans.evidencePending"));
}
lines.push(...formatActiveEvidenceLines(active.stepCompletions, active.completedStepIds));
} else {
lines.push(t("handlers.plans.activeNone"));
}
@@ -55,6 +61,8 @@ const plans: SlashHandler = (args, loop, ctx) => {
label,
}),
);
const evidence = formatArchivedEvidenceSummary(a.stepCompletions, a.completedStepIds);
if (evidence) lines.push(t("handlers.plans.archivedEvidenceLine", { summary: evidence }));
}
return { info: lines.join("\n") };
};
@@ -123,6 +131,54 @@ function handleDone(rest: string[], ctx: Parameters<SlashHandler>[2]): { info: s
}
}
function formatActiveEvidenceLines(
completions: Record<string, StepCompletion> | undefined,
completedStepIds: readonly string[],
): string[] {
if (!completions) return [];
const lines: string[] = [];
for (const stepId of completedStepIds) {
const summary = formatCompletionEvidenceSummary(completions[stepId]);
if (!summary) continue;
lines.push(t("handlers.plans.evidenceLine", { stepId, summary }));
}
return lines;
}
function formatArchivedEvidenceSummary(
completions: Record<string, StepCompletion> | undefined,
completedStepIds: readonly string[],
): string | null {
if (!completions) return null;
const parts: string[] = [];
for (const stepId of completedStepIds) {
const summary = formatCompletionEvidenceSummary(completions[stepId]);
if (!summary) continue;
parts.push(`${stepId} ${summary}`);
}
return parts.length > 0 ? parts.join("; ") : null;
}
function formatCompletionEvidenceSummary(completion: StepCompletion | undefined): string | null {
const evidence = completion?.evidence;
if (!evidence || evidence.length === 0) {
const compact = completion?.evidenceSummary?.trim();
return compact || null;
}
const parts = evidence.map(formatEvidenceItem).filter((part) => part.length > 0);
return parts.length > 0 ? parts.join("; ") : null;
}
function formatEvidenceItem(evidence: StepEvidence): string {
const extras: string[] = [];
if (evidence.command) extras.push(evidence.command);
if (evidence.paths && evidence.paths.length > 0) {
extras.push(evidence.paths.slice(0, 3).join(", "));
}
const suffix = extras.length > 0 ? ` (${extras.join("; ")})` : "";
return `${evidence.kind} - ${evidence.summary}${suffix}`;
}
export const handlers: Record<string, SlashHandler> = {
plans,
replay,
+3 -2
View File
@@ -1,4 +1,5 @@
import { resolveThemePreference, saveTheme } from "@/config.js";
import { t } from "../../../../i18n/index.js";
import { type ThemeName, isThemeName, listThemeNames } from "../../theme/tokens.js";
import type { SlashHandler } from "../dispatch.js";
@@ -13,12 +14,12 @@ const theme: SlashHandler = (args) => {
if (!next) return { openThemePicker: true };
if (!isThemeChoice(next)) {
return { info: `unknown theme: ${next}\navailable: ${themeChoices.join(", ")}` };
return { info: t("app.unknownTheme", { name: next, choices: themeChoices.join(", ") }) };
}
saveTheme(next);
const active = resolveThemePreference(next, process.env.REASONIX_THEME);
return { info: `theme saved: ${next}\nactive on next launch: ${active}` };
return { info: t("app.themeSaved", { name: next, active }) };
};
export const handlers: Record<string, SlashHandler> = {
@@ -16,7 +16,7 @@ export const handlers: Record<string, SlashHandler> = {
const engine = args[0];
if (
!engine ||
(engine !== "mojeek" &&
(engine !== "bing" &&
engine !== "searxng" &&
engine !== "metaso" &&
engine !== "tavily" &&
@@ -29,7 +29,7 @@ export const handlers: Record<string, SlashHandler> = {
t("handlers.webSearchEngine.endpoint", { url: webSearchEndpoint() }),
"",
t("handlers.webSearchEngine.usageHeader"),
t("handlers.webSearchEngine.usageMojeek"),
t("handlers.webSearchEngine.usageBing"),
t("handlers.webSearchEngine.usageSearxng"),
t("handlers.webSearchEngine.usageSearxngUrl"),
t("handlers.webSearchEngine.usageMetaso"),
-2
View File
@@ -21,8 +21,6 @@ export interface SlashResult {
openThemePicker?: boolean;
/** Open the unified MCP hub — `/mcp` defaults to "live", `/mcp browse` to "marketplace". */
openMcpHub?: { tab: "live" | "marketplace" };
/** Open the vim/tmux-style copy mode — yank chat text to clipboard via OSC 52. */
openCopyMode?: boolean;
/** Open the arg-completer picker for this command (e.g. `/language` → language picker). */
openArgPickerFor?: string;
/** Exit the app. */
-48
View File
@@ -1,48 +0,0 @@
import React from "react";
import {
type ChatScrollState,
type ChatScrollStore,
createChatScrollStore,
} from "./chat-scroll-store.js";
const Ctx = React.createContext<ChatScrollStore | null>(null);
export function ChatScrollProvider({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
const store = React.useMemo(() => createChatScrollStore(), []);
return <Ctx.Provider value={store}>{children}</Ctx.Provider>;
}
function useStore(): ChatScrollStore {
const s = React.useContext(Ctx);
if (!s) throw new Error("useChatScroll* must be used inside ChatScrollProvider");
return s;
}
/** Subscribes to a slice of scroll state — only re-renders when that slice changes. */
export function useChatScrollState<T>(selector: (s: ChatScrollState) => T): T {
const store = useStore();
const subscribe = React.useCallback((cb: () => void) => store.subscribe(cb), [store]);
const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/** Returns the action set — stable across renders, never triggers re-renders by itself. */
export function useChatScrollActions(): Pick<
ChatScrollStore,
| "scrollUp"
| "scrollDown"
| "scrollPageUp"
| "scrollPageDown"
| "scrollWheelUp"
| "scrollWheelDown"
| "jumpToBottom"
| "setMaxScroll"
| "setCardHeight"
| "pruneCardHeights"
> {
return useStore();
}
-197
View File
@@ -1,197 +0,0 @@
/** Chat-scroll state in its own store so wheel/arrow ticks don't dirty App.tsx. */
export interface ChatScrollState {
/** Rows of content above the visible viewport. */
scrollRows: number;
/** True while following the bottom — auto-advances on new content. */
pinned: boolean;
/** Total scrollable rows; CardStream reports this once Yoga has measured. */
maxScroll: number;
/** Bumped on every applied scroll delta — consumers can flash an indicator. */
scrollVersion: number;
/** Per-card row height, populated as cards mount and re-measured on streaming changes. */
cardHeights: ReadonlyMap<string, number>;
}
export type ScrollListener = () => void;
export interface ChatScrollStore {
getState(): ChatScrollState;
subscribe(listener: ScrollListener): () => void;
scrollUp(): void;
scrollDown(): void;
scrollPageUp(): void;
scrollPageDown(): void;
scrollWheelUp(): void;
scrollWheelDown(): void;
jumpToBottom(): void;
setMaxScroll(rows: number): void;
/** Reports a card's measured height. No-op if value matches the cache. */
setCardHeight(id: string, rows: number): void;
/** Drops heights for cards no longer in the visible list. Called by CardStream when cards change. */
pruneCardHeights(liveIds: ReadonlySet<string>): void;
}
export const SCROLL_ARROW_ROWS = 3;
export const SCROLL_PAGE_ROWS = 5;
/** One wheel notch on most mice emits 2-5 SGR mouse reports back-to-back,
* so anything larger here multiplies into a 10-25 row jump per notch. */
export const SCROLL_WHEEL_ROWS = 1;
const COALESCE_MS = 16;
const EMPTY_HEIGHTS: ReadonlyMap<string, number> = new Map();
const initial: ChatScrollState = {
scrollRows: 0,
pinned: true,
maxScroll: 0,
scrollVersion: 0,
cardHeights: EMPTY_HEIGHTS,
};
export function createChatScrollStore(): ChatScrollStore {
let state = initial;
const listeners = new Set<ScrollListener>();
let pendingDelta = 0;
let flushTimer: NodeJS.Timeout | null = null;
// Trailing-edge coalesce target for pinned-mode shrinks (issue #653).
// While a burst of card-collapse re-measurements arrives, we hold the latest
// target here and apply it once on a microtask flush, so subscribers see one
// settled transition instead of N oscillating snaps.
// Grows still apply immediately — streaming content should auto-scroll without
// latency. Growth oscillation is prevented upstream by the monotonic height
// lock in CardStream.MeasuredCard: card heights only increase during streaming,
// so maxScroll growth is naturally monotonic.
let pendingMaxShrink: number | null = null;
let shrinkTimer: NodeJS.Timeout | null = null;
function set(next: Partial<ChatScrollState>): void {
const merged = { ...state, ...next };
if (
merged.scrollRows === state.scrollRows &&
merged.pinned === state.pinned &&
merged.maxScroll === state.maxScroll &&
merged.scrollVersion === state.scrollVersion &&
merged.cardHeights === state.cardHeights
) {
return;
}
state = merged;
for (const l of listeners) l();
}
function applyDelta(): void {
const d = pendingDelta;
pendingDelta = 0;
if (d === 0) return;
const next = Math.max(0, Math.min(state.maxScroll, state.scrollRows + d));
set({
scrollRows: next,
pinned: d < 0 ? false : next >= state.maxScroll ? true : state.pinned,
scrollVersion: state.scrollVersion + 1,
});
}
/** Leading-edge: first tick flushes immediately, rest coalesce into one trailing flush. */
function schedule(delta: number): void {
if (flushTimer === null) {
pendingDelta = delta;
applyDelta();
flushTimer = setTimeout(() => {
flushTimer = null;
if (pendingDelta !== 0) applyDelta();
}, COALESCE_MS);
} else {
pendingDelta += delta;
}
}
function flushShrink(): void {
if (shrinkTimer !== null) {
clearTimeout(shrinkTimer);
shrinkTimer = null;
}
const target = pendingMaxShrink;
pendingMaxShrink = null;
if (target === null) return;
const nextScrollRows = state.pinned ? target : Math.min(state.scrollRows, target);
set({ maxScroll: target, scrollRows: nextScrollRows });
}
return {
getState() {
return state;
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
scrollUp: () => schedule(-SCROLL_ARROW_ROWS),
scrollDown: () => schedule(SCROLL_ARROW_ROWS),
scrollPageUp: () => schedule(-SCROLL_PAGE_ROWS),
scrollPageDown: () => schedule(SCROLL_PAGE_ROWS),
scrollWheelUp: () => schedule(-SCROLL_WHEEL_ROWS),
scrollWheelDown: () => schedule(SCROLL_WHEEL_ROWS),
jumpToBottom() {
pendingDelta = 0;
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTimer = null;
}
// Drop any deferred shrink so an explicit jump isn't undone by a stale target.
pendingMaxShrink = null;
if (shrinkTimer !== null) {
clearTimeout(shrinkTimer);
shrinkTimer = null;
}
set({ pinned: true });
},
setMaxScroll(rows: number) {
const m = rows < 0 ? 0 : rows;
// Coalesce shrinks while pinned (issue #653): a burst of card-teardown
// re-measurements during an Esc-abort would otherwise snap scrollRows N
// times, producing a visible flicker. Grows still apply immediately so
// normal streaming output keeps the viewport pinned without latency.
// Growth oscillation is prevented upstream by the monotonic height lock
// in CardStream.MeasuredCard — card heights only increase during streaming,
// so maxScroll growth is naturally monotonic without coalescing.
const currentMax = pendingMaxShrink ?? state.maxScroll;
if (state.pinned && m < currentMax) {
pendingMaxShrink = m;
if (shrinkTimer === null) {
shrinkTimer = setTimeout(() => {
shrinkTimer = null;
flushShrink();
}, COALESCE_MS);
}
return;
}
// Non-shrink path: flush any deferred shrink first so its trailing state
// doesn't clobber the value we're about to set.
if (pendingMaxShrink !== null) flushShrink();
// Pinned-mode invariant: scrollRows tracks maxScroll exactly.
const nextScrollRows = state.pinned ? m : Math.min(state.scrollRows, m);
set({ maxScroll: m, scrollRows: nextScrollRows });
},
setCardHeight(id: string, rows: number) {
if (state.cardHeights.get(id) === rows) return;
const next = new Map(state.cardHeights);
next.set(id, rows);
set({ cardHeights: next });
},
pruneCardHeights(liveIds: ReadonlySet<string>) {
let drop = 0;
for (const id of state.cardHeights.keys()) {
if (!liveIds.has(id)) drop++;
}
if (drop === 0) return;
const next = new Map<string, number>();
for (const [id, h] of state.cardHeights) {
if (liveIds.has(id)) next.set(id, h);
}
set({ cardHeights: next });
},
};
}
@@ -7,7 +7,7 @@ export function graphemes(s: string): string[] {
return Array.from(segmenter.segment(s), (seg) => seg.segment);
}
/** Clamp into {0,1,2} — Frame grid only knows narrow + wide cells. */
/** Narrow=1 / wide=2 / zero-width=0 cell width for a single grapheme cluster. */
export function graphemeWidth(g: string): 0 | 1 | 2 {
if (g.length === 0) return 0;
const w = stringWidthLib(g);
+10 -1
View File
@@ -166,10 +166,19 @@ export function useCompletionPickers({
loading: browse.loading,
};
}
// When the user already typed a directory prefix (e.g. `@dir/fil`),
// filter search results to only files under that directory so a
// shorter same-name match from elsewhere (e.g. root `.gitignore`)
// doesn't steal the picker selection.
const dirPrefix = parsed.dir ? `${parsed.dir}/` : "";
let filtered = search.entries;
if (dirPrefix) {
filtered = search.entries.filter((e) => e.insertPath.startsWith(dirPrefix));
}
return {
kind: "search",
filter: parsed.filter,
entries: search.entries,
entries: filtered,
scanned: search.scanned,
searching: search.searching,
};
+15 -4
View File
@@ -223,8 +223,13 @@ export class DeepSeekClient {
async chat(opts: ChatRequestOptions): Promise<ChatResponse> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
const signal = opts.signal ?? ctrl.signal;
const timer = setTimeout(
() => ctrl.abort(new Error(`DeepSeek request timed out after ${this.timeoutMs}ms`)),
this.timeoutMs,
);
// Combine — `opts.signal ?? ctrl.signal` orphans the timer when the
// caller passes a signal, so timeoutMs never reaches fetch.
const signal = opts.signal ? AbortSignal.any([opts.signal, ctrl.signal]) : ctrl.signal;
try {
await this.waitForChatRateLimit(signal);
@@ -261,8 +266,14 @@ export class DeepSeekClient {
async *stream(opts: ChatRequestOptions): AsyncGenerator<StreamChunk> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
const signal = opts.signal ?? ctrl.signal;
const timer = setTimeout(
() => ctrl.abort(new Error(`DeepSeek stream timed out after ${this.timeoutMs}ms`)),
this.timeoutMs,
);
// Combine — `opts.signal ?? ctrl.signal` orphans the timer when the
// caller passes a signal, leaving a stalled SSE body to hang forever
// on reader.read() (issue #1535).
const signal = opts.signal ? AbortSignal.any([opts.signal, ctrl.signal]) : ctrl.signal;
let resp: Response;
try {
+11 -5
View File
@@ -14,6 +14,7 @@ import {
writeSync,
} from "node:fs";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import { type FileEncoding, decodeFileBuffer, encodeFile } from "./file-encoding.js";
export interface EditBlock {
/** Path as written by the model — relative to rootDir, or absolute. */
@@ -157,7 +158,7 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
if (n <= 0) break;
readBytes += n;
}
const content = inBuf.toString("utf8", 0, readBytes);
const { text: content, encoding } = decodeFileBuffer(inBuf.subarray(0, readBytes));
const le = lineEndingOf(content);
const adaptedSearch = block.search.replace(/\r?\n/g, le);
const adaptedReplace = block.replace.replace(/\r?\n/g, le);
@@ -184,7 +185,7 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
// Truncate first so a shorter result doesn't leave stale tail
// bytes; ftruncate also pads with NUL when the new length is
// longer, which we then overwrite below.
const outBuf = Buffer.from(replaced, "utf8");
const outBuf = encodeFile(replaced, encoding);
ftruncateSync(fd, outBuf.length);
let written = 0;
while (written < outBuf.length) {
@@ -210,7 +211,7 @@ export function toWholeFileEditBlock(path: string, content: string, rootDir: str
let search = "";
if (existsSync(abs)) {
try {
search = readFileSync(abs, "utf8");
search = decodeFileBuffer(readFileSync(abs)).text;
} catch {
search = "";
}
@@ -223,14 +224,18 @@ export interface EditSnapshot {
path: string;
/** `null` = file didn't exist; restore means delete. */
prevContent: string | null;
/** Encoding the file used before the edit. Required to round-trip GB18030 / UTF-8-BOM on restore. */
prevEncoding?: FileEncoding;
}
/** De-duped by path — one "before" snapshot per file even with multiple blocks. */
export function snapshotBeforeEdits(blocks: EditBlock[], rootDir: string): EditSnapshot[] {
const absRoot = resolve(rootDir);
const seen = new Set<string>();
const snapshots: EditSnapshot[] = [];
for (const b of blocks) {
const abs = resolveEditPath(rootDir, b.path);
if (!pathIsUnder(abs, absRoot)) continue;
if (seen.has(abs)) continue;
seen.add(abs);
if (!existsSync(abs)) {
@@ -238,7 +243,8 @@ export function snapshotBeforeEdits(blocks: EditBlock[], rootDir: string): EditS
continue;
}
try {
snapshots.push({ path: b.path, prevContent: readFileSync(abs, "utf8") });
const { text, encoding } = decodeFileBuffer(readFileSync(abs));
snapshots.push({ path: b.path, prevContent: text, prevEncoding: encoding });
} catch {
// Unreadable (permission / binary) — record null so we at least
// don't pretend the snapshot is authoritative. The restore path
@@ -270,7 +276,7 @@ export function restoreSnapshots(snapshots: EditSnapshot[], rootDir: string): Ap
message: "removed (the edit had created it)",
};
}
writeFileSync(abs, snap.prevContent, "utf8");
writeFileSync(abs, encodeFile(snap.prevContent, snap.prevEncoding ?? "utf8"));
return {
path: snap.path,
status: "applied",
+62
View File
@@ -0,0 +1,62 @@
/** Read/write preserves the file's original encoding so edit_file on GB18030 (CN Windows) or UTF-8-BOM files doesn't silently convert or fail SEARCH on mangled decode (issue #1445). */
import { promises as fsp, readFileSync, writeFileSync } from "node:fs";
import iconv from "iconv-lite";
export type FileEncoding = "utf8" | "utf8-bom" | "gb18030";
export interface DecodedFile {
text: string;
encoding: FileEncoding;
}
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
export function decodeFileBuffer(buf: Buffer): DecodedFile {
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
return { text: buf.subarray(3).toString("utf8"), encoding: "utf8-bom" };
}
try {
return { text: new TextDecoder("utf-8", { fatal: true }).decode(buf), encoding: "utf8" };
} catch {
// Fall through.
}
// GB18030 isn't platform-conditional — files originated on CN Windows can travel anywhere.
try {
return {
text: new TextDecoder("gb18030", { fatal: true }).decode(buf),
encoding: "gb18030",
};
} catch {
// Fall through.
}
return { text: buf.toString("utf8"), encoding: "utf8" };
}
export function encodeFile(text: string, encoding: FileEncoding): Buffer {
if (encoding === "utf8") return Buffer.from(text, "utf8");
if (encoding === "utf8-bom") {
return Buffer.concat([UTF8_BOM, Buffer.from(text, "utf8")]);
}
return iconv.encode(text, "gb18030");
}
export function readTextFileSmartSync(path: string): DecodedFile {
return decodeFileBuffer(readFileSync(path));
}
export async function readTextFileSmart(path: string): Promise<DecodedFile> {
return decodeFileBuffer(await fsp.readFile(path));
}
export function writeTextFileSmartSync(path: string, text: string, encoding: FileEncoding): void {
writeFileSync(path, encodeFile(text, encoding));
}
export async function writeTextFileSmart(
path: string,
text: string,
encoding: FileEncoding,
): Promise<void> {
await fsp.writeFile(path, encodeFile(text, encoding));
}
+212
View File
@@ -0,0 +1,212 @@
export type LifecyclePolicyRisk = "safe" | "mutation" | "high-risk";
export type LifecyclePolicyReason =
| "safe-tool"
| "mutation-tool"
| "high-risk-tool"
| "package-or-config-path"
| "high-risk-command"
| "default-safe";
export interface LifecyclePolicyDecision {
toolName: string;
risk: LifecyclePolicyRisk;
reason: LifecyclePolicyReason;
}
const SAFE_TOOL_NAMES = new Set([
"read_file",
"list_directory",
"directory_tree",
"search_files",
"search_content",
"glob",
"get_file_info",
"semantic_search",
"web_search",
"web_fetch",
"recall_memory",
"todo_write",
"ask_choice",
"submit_plan",
"mark_step_complete",
"revise_plan",
"job_output",
"wait_for_job",
"list_jobs",
]);
const HIGH_RISK_TOOL_NAMES = new Set([
"multi_edit",
"move_file",
"delete_file",
"delete_directory",
"copy_file",
"create_directory",
"run_background",
"stop_job",
]);
const MUTATION_TOOL_NAMES = new Set([
"edit_file",
"write_file",
"multi_edit",
"move_file",
"delete_file",
"delete_directory",
"copy_file",
"create_directory",
"run_background",
"stop_job",
]);
export function classifyLifecycleToolCall(
toolName: string,
args: Record<string, unknown>,
): LifecyclePolicyDecision {
if (HIGH_RISK_TOOL_NAMES.has(toolName)) {
return decision(toolName, "high-risk", "high-risk-tool");
}
if (SAFE_TOOL_NAMES.has(toolName)) {
return decision(toolName, "safe", "safe-tool");
}
if (toolName === "write_file" || toolName === "edit_file") {
const path = typeof args.path === "string" ? args.path : "";
if (isPackageOrConfigPath(path)) {
return decision(toolName, "high-risk", "package-or-config-path");
}
return decision(toolName, "mutation", "mutation-tool");
}
if (toolName === "run_command") {
const command = typeof args.command === "string" ? args.command : "";
if (isHighRiskCommand(command)) {
return decision(toolName, "high-risk", "high-risk-command");
}
return decision(toolName, "safe", "default-safe");
}
if (MUTATION_TOOL_NAMES.has(toolName)) {
return decision(toolName, "mutation", "mutation-tool");
}
return decision(toolName, "safe", "default-safe");
}
function decision(
toolName: string,
risk: LifecyclePolicyRisk,
reason: LifecyclePolicyReason,
): LifecyclePolicyDecision {
return { toolName, risk, reason };
}
function isPackageOrConfigPath(path: string): boolean {
const normalized = path.replaceAll("\\", "/").toLowerCase();
return (
/(^|\/)package(-lock)?\.json$/.test(normalized) ||
/(^|\/)pnpm-lock\.yaml$/.test(normalized) ||
/(^|\/)yarn\.lock$/.test(normalized) ||
/(^|\/)tsconfig[^/]*\.json$/.test(normalized) ||
/(^|\/)vitest\.config\./.test(normalized) ||
/(^|\/)biome\.json$/.test(normalized) ||
normalized.startsWith(".github/workflows/")
);
}
function isHighRiskCommand(command: string): boolean {
const tokens = shellTokens(command);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]?.toLowerCase();
if (!token || !isCommandPosition(tokens, i)) continue;
if (
(token === "npm" || token === "pnpm" || token === "yarn") &&
isPackageMutation(tokens[i + 1])
) {
return true;
}
if (token === "git" && isHighRiskGitCommand(tokens.slice(i + 1))) return true;
if (token === "rm" || token === "mv" || token === "cp") return true;
}
return false;
}
function shellTokens(command: string): string[] {
const out: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
for (let i = 0; i < command.length; i++) {
const ch = command[i] ?? "";
if (quote) {
if (ch === quote) quote = null;
else current += ch;
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current) {
out.push(current);
current = "";
}
continue;
}
if (ch === ";" || ch === "|" || ch === "&") {
if (current) {
out.push(current);
current = "";
}
const next = command[i + 1];
if ((ch === "|" || ch === "&") && next === ch) {
out.push(`${ch}${next}`);
i++;
} else {
out.push(ch);
}
continue;
}
current += ch;
}
if (current) out.push(current);
return out;
}
function isCommandPosition(tokens: string[], index: number): boolean {
if (index === 0) return true;
const previous = tokens[index - 1];
return previous === ";" || previous === "|" || previous === "&&" || previous === "||";
}
function isPackageMutation(token: string | undefined): boolean {
const normalized = token?.toLowerCase();
return (
normalized === "install" ||
normalized === "add" ||
normalized === "remove" ||
normalized === "update"
);
}
function isHighRiskGitCommand(args: string[]): boolean {
const subcommandIndex = args.findIndex((arg) => arg && !arg.startsWith("-"));
const subcommand = args[subcommandIndex]?.toLowerCase();
if (!subcommand) return false;
if (
subcommand === "push" ||
subcommand === "reset" ||
subcommand === "clean" ||
subcommand === "switch"
) {
return true;
}
if (subcommand !== "checkout") return false;
const checkoutArgs = args.slice(subcommandIndex + 1);
if (checkoutArgs[0] === "--") return false;
if (checkoutArgs.some((arg) => arg === "-b" || arg === "-B" || arg === "--orphan")) return true;
const positional = checkoutArgs.filter((arg) => arg && !arg.startsWith("-"));
if (positional.length === 0) return false;
return !positional.every(looksLikePathCheckout);
}
function looksLikePathCheckout(arg: string): boolean {
return arg.includes("/") || arg.includes("\\") || arg.includes(".");
}
+3 -180
View File
@@ -1,5 +1,6 @@
import type { ToolInterceptor } from "../tools.js";
import type { PlanStep, StepEvidence } from "../tools/plan.js";
import { classifyLifecycleToolCall } from "./lifecycle-policy.js";
export type EngineeringLifecycleMode = "off" | "strict";
export type EngineeringLifecycleState =
@@ -24,77 +25,12 @@ export interface EngineeringLifecycleOptions {
mode?: EngineeringLifecycleMode;
}
const SAFE_TOOL_NAMES = new Set([
"read_file",
"list_directory",
"directory_tree",
"search_files",
"search_content",
"glob",
"get_file_info",
"semantic_search",
"web_search",
"web_fetch",
"recall_memory",
"todo_write",
"ask_choice",
"submit_plan",
"mark_step_complete",
"revise_plan",
"job_output",
"wait_for_job",
"list_jobs",
]);
const HIGH_RISK_TOOL_NAMES = new Set([
"multi_edit",
"move_file",
"delete_file",
"delete_directory",
"copy_file",
"create_directory",
"run_background",
"stop_job",
]);
const MUTATION_TOOL_NAMES = new Set([
"edit_file",
"write_file",
"multi_edit",
"move_file",
"delete_file",
"delete_directory",
"copy_file",
"create_directory",
"run_background",
"stop_job",
]);
export function isHighRiskLifecycleToolCall(name: string, args: Record<string, unknown>): boolean {
if (HIGH_RISK_TOOL_NAMES.has(name)) return true;
if (SAFE_TOOL_NAMES.has(name)) return false;
if (name === "write_file") {
const path = typeof args.path === "string" ? args.path : "";
return isPackageOrConfigPath(path);
}
if (name === "edit_file") {
const path = typeof args.path === "string" ? args.path : "";
return isPackageOrConfigPath(path);
}
if (name === "run_command") {
const command = typeof args.command === "string" ? args.command : "";
return isHighRiskCommand(command);
}
return false;
return classifyLifecycleToolCall(name, args).risk === "high-risk";
}
export function isLifecycleMutationToolCall(name: string, args: Record<string, unknown>): boolean {
if (MUTATION_TOOL_NAMES.has(name)) return true;
if (name === "run_command") {
const command = typeof args.command === "string" ? args.command : "";
return isHighRiskCommand(command);
}
return false;
return classifyLifecycleToolCall(name, args).risk !== "safe";
}
export class EngineeringLifecycleRuntime {
@@ -270,116 +206,3 @@ function toolResultLooksSuccessful(result: string): boolean {
text,
);
}
function isPackageOrConfigPath(path: string): boolean {
const normalized = path.replaceAll("\\", "/").toLowerCase();
return (
/(^|\/)package(-lock)?\.json$/.test(normalized) ||
/(^|\/)pnpm-lock\.yaml$/.test(normalized) ||
/(^|\/)yarn\.lock$/.test(normalized) ||
/(^|\/)tsconfig[^/]*\.json$/.test(normalized) ||
/(^|\/)vitest\.config\./.test(normalized) ||
/(^|\/)biome\.json$/.test(normalized) ||
normalized.startsWith(".github/workflows/")
);
}
function isHighRiskCommand(command: string): boolean {
const tokens = shellTokens(command);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]?.toLowerCase();
if (!token || !isCommandPosition(tokens, i)) continue;
if (
(token === "npm" || token === "pnpm" || token === "yarn") &&
isPackageMutation(tokens[i + 1])
) {
return true;
}
if (token === "git" && isHighRiskGitCommand(tokens.slice(i + 1))) return true;
if (token === "rm" || token === "mv" || token === "cp") return true;
}
return false;
}
function shellTokens(command: string): string[] {
const out: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
for (let i = 0; i < command.length; i++) {
const ch = command[i] ?? "";
if (quote) {
if (ch === quote) quote = null;
else current += ch;
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current) {
out.push(current);
current = "";
}
continue;
}
if (ch === ";" || ch === "|" || ch === "&") {
if (current) {
out.push(current);
current = "";
}
const next = command[i + 1];
if ((ch === "|" || ch === "&") && next === ch) {
out.push(`${ch}${next}`);
i++;
} else {
out.push(ch);
}
continue;
}
current += ch;
}
if (current) out.push(current);
return out;
}
function isCommandPosition(tokens: string[], index: number): boolean {
if (index === 0) return true;
const previous = tokens[index - 1];
return previous === ";" || previous === "|" || previous === "&&" || previous === "||";
}
function isPackageMutation(token: string | undefined): boolean {
const normalized = token?.toLowerCase();
return (
normalized === "install" ||
normalized === "add" ||
normalized === "remove" ||
normalized === "update"
);
}
function isHighRiskGitCommand(args: string[]): boolean {
const subcommandIndex = args.findIndex((arg) => arg && !arg.startsWith("-"));
const subcommand = args[subcommandIndex]?.toLowerCase();
if (!subcommand) return false;
if (
subcommand === "push" ||
subcommand === "reset" ||
subcommand === "clean" ||
subcommand === "switch"
) {
return true;
}
if (subcommand !== "checkout") return false;
const checkoutArgs = args.slice(subcommandIndex + 1);
if (checkoutArgs[0] === "--") return false;
if (checkoutArgs.some((arg) => arg === "-b" || arg === "-B" || arg === "--orphan")) return true;
const positional = checkoutArgs.filter((arg) => arg && !arg.startsWith("-"));
if (positional.length === 0) return false;
return !positional.every(looksLikePathCheckout);
}
function looksLikePathCheckout(arg: string): boolean {
return arg.includes("/") || arg.includes("\\") || arg.includes(".");
}
+5 -1
View File
@@ -68,7 +68,7 @@ the new lines
>>>>>>> REPLACE
Rules:
- read_file first so your SEARCH matches byte-for-byte.
- **Read before edit (enforced).** You MUST call \`read_file\` on the target this session before \`edit_file\` / \`multi_edit\` will accept it — the tool refuses unread targets up front, so SEARCH text is grounded in on-disk bytes, not a guess. A fold / mechanical truncate clears the tracker, so re-read after one of those before mutating. \`write_file\` counts as a read for that path (the content is what you just wrote).
- One edit per block; multiple blocks per response are fine.
- Create a new file with empty SEARCH:
path/to/new.ts
@@ -111,6 +111,10 @@ When the user says run / start / launch / serve / boot up: start it, verify it c
- One short paragraph explaining *why*, then the blocks.
- Silence during exploration is fine — tool calls first, prose after.
# Task integrity — non-negotiable
The user's original objective and ALL constraints (especially "do NOT do X", "avoid Y", "never Z") remain in force for the entire session. You may NOT unilaterally simplify, narrow, or change the objective to save tokens, time, or steps. If you believe the objective needs adjustment, ask the user — do NOT decide on your own.
__ESCALATION_CONTRACT__
${TUI_FORMATTING_RULES}
+2 -1
View File
@@ -6,6 +6,7 @@ import {
loadJavaSourceEnabled,
loadProjectShellAllowed,
loadResolvedSkillPaths,
loadToolRateLimit,
readConfig,
searchEnabled,
} from "../config.js";
@@ -49,7 +50,7 @@ export interface CodeToolset {
}
export async function buildCodeToolset(opts: CodeToolsetOpts): Promise<CodeToolset> {
const tools = new ToolRegistry();
const tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
const jobs = new JobRegistry();
const outlineThresholdBytes = loadFilesystemOutlineThresholdBytes();
+88 -7
View File
@@ -1,6 +1,13 @@
/** Library reads only DEEPSEEK_API_KEY from env; the CLI bridges config.json → env var. */
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import {
appendFileSync,
chmodSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { z } from "zod";
@@ -13,6 +20,11 @@ import {
} from "./index/config.js";
import { type McpServerSpec, parseMcpSpec } from "./mcp/spec.js";
import { normalizeQQAllowlist, normalizeQQOpenId } from "./qq/access.js";
import {
type NormalizedToolRateLimitConfig,
type ToolRateLimitConfig,
normalizeToolRateLimitConfig,
} from "./tools/rate-limit.js";
/** Legacy `fast|smart|max` kept for back-compat with existing config.json files. */
export type PresetName = "auto" | "flash" | "pro" | "fast" | "smart" | "max";
@@ -110,6 +122,7 @@ export interface PricingOverride {
}
export interface RateLimitConfig {
/** Client-side self-throttle in requests/minute — paces outbound chat calls with a min-interval timer. NOT a DeepSeek-enforced limit: DeepSeek's actual cap is concurrency, not RPM (500 for v4-pro, 2500 for v4-flash, account-wide), surfaced as HTTP 429. Set this only to be a polite neighbor on shared infra; single-user CLI rarely needs it. */
rpm?: number;
}
@@ -118,6 +131,8 @@ export interface ProxyConfig {
disabled?: boolean;
/** Additional NO_PROXY patterns (curl syntax). Additive on top of env NO_PROXY and the default DeepSeek-bypass whitelist. */
noProxy?: string[];
/** When false, route api.deepseek.com / *.deepseek.com through the proxy too (issue #1497 — corporate firewalls that block direct egress). Default true preserves the clash/v2ray US-exit-IP 403 fix. Env `REASONIX_PROXY_DEEPSEEK_DIRECT` overrides. */
bypassDeepSeekDirect?: boolean;
}
export interface ReasonixConfig {
@@ -151,8 +166,8 @@ export interface ReasonixConfig {
session?: string | null;
setupCompleted?: boolean;
search?: boolean;
/** Web search engine backend: "mojeek" (default, scrapes Mojeek), "searxng" (self-hosted SearXNG), "metaso" (Metaso API), "tavily" (LLM-friendly API, free tier), "perplexity" (Perplexity AI), or "exa" (Exa API). */
webSearchEngine?: "mojeek" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa";
/** Web search engine backend: "bing" (default, scrapes cn.bing.com), "searxng" (self-hosted SearXNG), "metaso" (Metaso API), "tavily" (LLM-friendly API, free tier), "perplexity" (Perplexity AI), or "exa" (Exa API). */
webSearchEngine?: "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa";
/** Base URL for SearXNG instance (default http://localhost:8080). */
webSearchEndpoint?: string;
/** Metaso API key. Falls back to METASO_API_KEY env var. */
@@ -166,6 +181,8 @@ export interface ReasonixConfig {
/** TUI mouse-wheel scrolling via SGR mouse tracking. Default true. Set false to fall back to native terminal drag-select for copy (then wheel is terminal-dependent — most terminals translate wheel→arrow in alt-screen, some don't). */
mouseTracking?: boolean;
/** Rows scrolled per single SGR mouse-wheel report. Default 1 — most terminals emit 2-5 reports per physical notch, so 1 already produces 2-5 rows per notch (#1419). Bump to 3-5 only if your terminal emits one report per notch and scrolling feels slow (#1494). Clamped to [1, 10]. */
mouseWheelRows?: number;
dashboard?: {
/** Pin the embedded dashboard to a fixed port — required for stable SSH tunnels. 0/absent → ephemeral. */
port?: number;
@@ -216,6 +233,7 @@ export interface ReasonixConfig {
/** Per-app proxy override. Layered on top of HTTPS_PROXY / NO_PROXY env vars + the default DeepSeek-bypass whitelist. */
proxy?: ProxyConfig;
rateLimit?: RateLimitConfig;
toolRateLimit?: ToolRateLimitConfig;
/** Host-enforced engineering lifecycle. Defaults to off so opt-outs pay zero prefix cost. */
engineeringLifecycle?: {
mode?: EngineeringLifecycleMode;
@@ -392,13 +410,41 @@ export function readConfig(path: string = defaultConfigPath()): ReasonixConfig {
}
export function writeConfig(cfg: ReasonixConfig, path: string = defaultConfigPath()): void {
debugLogConfigWrite(cfg, path);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(cfg, null, 2), "utf8");
// Atomic — write to a sibling tmp then rename. A torn write (process
// killed mid-write, or another reader catching the file before
// writeFileSync finished) used to leave a 0-byte or truncated
// config.json, which readConfig would then parse as `{}` and the next
// saveX would silently overwrite every other field with that empty
// baseline (issue #1535 follow-on — preset reverting to default).
const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf8");
try {
chmodSync(path, 0o600);
chmodSync(tmp, 0o600);
} catch {
/* ignore on platforms without chmod */
}
renameSync(tmp, path);
}
/** Append timestamp + keys + preset + stack to REASONIX_DEBUG_PRESET when set. Catches every persisted config change including the dashboard PATCH path that bypasses savePreset(). Zero cost when unset. */
function debugLogConfigWrite(cfg: ReasonixConfig, configPath: string): void {
const debugPath = process.env.REASONIX_DEBUG_PRESET;
if (!debugPath) return;
try {
const stack = new Error("trace").stack ?? "";
const keys = Object.keys(cfg).sort().join(",");
const presetField = cfg.preset === undefined ? "(absent)" : JSON.stringify(cfg.preset);
const line = `${new Date().toISOString()} writeConfig pid=${process.pid} → ${configPath}\n keys=[${keys}]\n preset=${presetField}\n${stack
.split("\n")
.slice(1, 10)
.map((l) => ` ${l.trim()}`)
.join("\n")}\n\n`;
appendFileSync(debugPath, line);
} catch {
/* diagnostic only */
}
}
/** Resolve the language from config file. */
@@ -583,6 +629,9 @@ export function loadProxyConfig(path: string = defaultConfigPath()): ProxyConfig
);
if (entries.length > 0) out.noProxy = entries;
}
if (typeof cfg.bypassDeepSeekDirect === "boolean") {
out.bypassDeepSeekDirect = cfg.bypassDeepSeekDirect;
}
return out;
}
@@ -592,6 +641,18 @@ export function loadRateLimit(path: string = defaultConfigPath()): RateLimitConf
return { rpm };
}
export function loadMouseWheelRows(path: string = defaultConfigPath()): number | undefined {
const raw = readConfig(path).mouseWheelRows;
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) return undefined;
return Math.min(raw, 10);
}
export function loadToolRateLimit(
path: string = defaultConfigPath(),
): false | NormalizedToolRateLimitConfig {
return normalizeToolRateLimitConfig(readConfig(path).toolRateLimit);
}
export function saveBaseUrl(url: string, path: string = defaultConfigPath()): void {
const cfg = readConfig(path);
const trimmed = url.trim();
@@ -746,14 +807,17 @@ export function loadJavaSourceEnabled(path: string = defaultConfigPath()): boole
export function webSearchEngine(
path: string = defaultConfigPath(),
): "mojeek" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa" {
): "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa" {
const cfg = readConfig(path).webSearchEngine;
if (cfg === "searxng") return "searxng";
if (cfg === "metaso") return "metaso";
if (cfg === "tavily") return "tavily";
if (cfg === "perplexity") return "perplexity";
if (cfg === "exa") return "exa";
return "mojeek";
// Any other value (including legacy "mojeek" from configs predating the
// engine swap) falls through to bing. Read-only — we never rewrite the
// user's config, so `/search-engine mojeek` later still rejects loudly.
return "bing";
}
export function webSearchEndpoint(path: string = defaultConfigPath()): string {
@@ -1098,11 +1162,28 @@ export function loadPreset(path: string = defaultConfigPath()): PresetName | und
/** Persist preset so `/preset pro` (or `/model deepseek-v4-pro`) sticks across relaunches. */
export function savePreset(preset: PresetName, path: string = defaultConfigPath()): void {
debugLogPresetWrite(preset, path);
const cfg = readConfig(path);
cfg.preset = preset;
writeConfig(cfg, path);
}
function debugLogPresetWrite(preset: PresetName, configPath: string): void {
const debugPath = process.env.REASONIX_DEBUG_PRESET;
if (!debugPath) return;
try {
const stack = new Error("trace").stack ?? "";
const line = `${new Date().toISOString()} savePreset(${JSON.stringify(preset)}) → ${configPath}\n${stack
.split("\n")
.slice(1, 8)
.map((l) => ` ${l.trim()}`)
.join("\n")}\n\n`;
appendFileSync(debugPath, line);
} catch {
/* diagnostic only */
}
}
export function loadIndexUserConfig(path: string = defaultConfigPath()): IndexUserConfig {
return readConfig(path).index ?? {};
}
+59 -30
View File
@@ -1,7 +1,6 @@
import type { DeepSeekClient } from "./client.js";
import { Usage } from "./client.js";
import { healLoadedMessages } from "./loop.js";
import { thinkingModeForModel } from "./loop.js";
import { stripHallucinatedToolMarkup } from "./loop.js";
import { buildAssistantMessage } from "./loop/messages.js";
import { DEFAULT_MAX_RESULT_CHARS } from "./mcp/registry.js";
@@ -17,7 +16,16 @@ import {
estimateConversationTokens,
estimateRequestTokens,
} from "./tokenizer.js";
import type { ChatMessage } from "./types.js";
import type { ChatMessage, ToolSpec } from "./types.js";
function extractPinnedConstraints(systemPrompt: string): string {
// matchAll because the system prompt can carry multiple blocks under the same
// prefix — e.g. global User memory + per-project User memory, or several
// Project memory files. Single .match() would only grab the first.
const pattern =
/# (?:HIGH PRIORITY constraints|User memory|Project memory)[\s\S]*?(?=\n# |\n---|$)/g;
return Array.from(systemPrompt.matchAll(pattern), (m) => m[0]).join("\n\n");
}
/** Auto-fold when a turn's response shows promptTokens above this fraction of ctxMax. */
export const HISTORY_FOLD_THRESHOLD = 0.75;
@@ -58,6 +66,12 @@ export interface ContextManagerDeps {
sessionName: string | null;
getAbortSignal: () => AbortSignal;
getCurrentTurn: () => number;
getSystemPrompt: () => string;
/** Reuses the live prefix → fold summary call shares the cached bytes the main agent already paid for. */
getToolSpecs?: () => readonly ToolSpec[];
getFewShots?: () => readonly ChatMessage[];
/** Fired when the message log was rewritten by fold/mechanicalTruncate; lets the loop drop session-scoped caches whose validity rested on the elided history (e.g. read-before-edit tracker). */
onLogRewrite?: () => void;
}
export type PostUsageDecisionKind = "none" | "fold" | "exit-with-summary";
@@ -89,24 +103,33 @@ export interface FoldResult {
summaryChars: number;
}
// Stub pins in head so the summarizer doesn't paraphrase them; dedupe by name, last invocation wins.
function extractPinnedSkills(head: ChatMessage[]): {
stubbedHead: ChatMessage[];
pinnedBodies: string[];
} {
function buildFoldSummaryInstruction(pinnedSkillNames: string[]): string {
const base =
"Summarize the conversation above as one self-contained prose recap. Preserve the user's " +
"ORIGINAL OBJECTIVE (never paraphrase away negative constraints like 'do NOT do X'), all " +
"'do not' / 'never' / 'avoid' instructions, decisions reached, files inspected or modified, " +
"tool results still relevant, and any open todos. Skip turn-by-turn play-by-play. " +
"Output plain prose only — no tool calls, no markdown headings, no SEARCH/REPLACE blocks.";
if (pinnedSkillNames.length === 0) return base;
const list = pinnedSkillNames.map((n) => `"${n}"`).join(", ");
return `${base} The following skill memos are pinned verbatim and appended after your summary — do NOT quote or paraphrase their bodies: ${list}.`;
}
// Dedupe by name, last invocation wins. Read-only — leaves head bytes unchanged so the
// summarizer call's prefix still matches what the main agent already cached.
function collectPinnedSkills(head: ChatMessage[]): { names: string[]; bodies: string[] } {
const pinned = new Map<string, string>();
const stubbedHead = head.map((msg) => {
if (typeof msg.content !== "string") return msg;
let hit = false;
const next = msg.content.replace(SKILL_PIN_REGEX, (full, name: string) => {
for (const msg of head) {
if (typeof msg.content !== "string") continue;
SKILL_PIN_REGEX.lastIndex = 0;
for (const match of msg.content.matchAll(SKILL_PIN_REGEX)) {
const name = match[1] as string;
const full = match[0];
pinned.delete(name);
pinned.set(name, full);
hit = true;
return `[skill ${JSON.stringify(name)} memo — preserved separately, do not summarize.]`;
});
return hit ? { ...msg, content: next } : msg;
});
return { stubbedHead, pinnedBodies: [...pinned.values()] };
}
}
return { names: [...pinned.keys()], bodies: [...pinned.values()] };
}
export class ContextManager {
@@ -217,19 +240,23 @@ export class ContextManager {
const headTokens = totalTokens - cumTokens;
if (headTokens < totalTokens * HISTORY_FOLD_MIN_SAVINGS_FRACTION) return noop;
const { stubbedHead, pinnedBodies } = extractPinnedSkills(head);
const summary = await this.summarizeForFold(stubbedHead);
const { names: pinnedNames, bodies: pinnedBodies } = collectPinnedSkills(head);
const summary = await this.summarizeForFold(head, pinnedNames);
if (!summary.content) return noop;
const memoTail =
pinnedBodies.length > 0 ? `\n\n${SKILL_PIN_MEMO_HEADER}\n\n${pinnedBodies.join("\n\n")}` : "";
const constraints = extractPinnedConstraints(this.deps.getSystemPrompt());
const constraintTail = constraints
? `\n\n[PINNED CONSTRAINTS — preserved verbatim]\n\n${constraints}`
: "";
// Route via buildAssistantMessage so the synthetic summary carries
// reasoning_content under thinking-mode sessions — without it the
// next API call 400s with "must be passed back" (#1042). Stamp uses
// the SESSION model so an empty placeholder is added even when the
// summarizer call somehow returned no reasoning.
const summaryMsg = buildAssistantMessage(
HISTORY_FOLD_MARKER + summary.content + memoTail,
HISTORY_FOLD_MARKER + summary.content + memoTail + constraintTail,
[],
model,
summary.reasoningContent,
@@ -237,6 +264,7 @@ export class ContextManager {
const replacement = [summaryMsg, ...tail];
this.deps.log.compactInPlace(replacement);
this.persistRewrite(replacement);
this.deps.onLogRewrite?.();
return {
folded: true,
beforeMessages: all.length,
@@ -301,6 +329,7 @@ export class ContextManager {
if (replacement.length === all.length) return noop;
this.deps.log.compactInPlace(replacement);
this.persistRewrite(replacement);
this.deps.onLogRewrite?.();
return {
folded: true,
beforeMessages: all.length,
@@ -328,19 +357,19 @@ export class ContextManager {
private async summarizeForFold(
messagesToSummarize: ChatMessage[],
pinnedSkillNames: string[],
): Promise<{ content: string; reasoningContent: string }> {
const summaryModel = "deepseek-v4-flash";
const systemPrompt =
"You compress conversation history for a coding agent. Output one prose recap that preserves: the user's overall goal, decisions and conclusions reached, files inspected or modified, important tool results still relevant to ongoing work, and any open todos. Skip turn-by-turn play-by-play. No tool calls, no markdown headings, no SEARCH/REPLACE blocks — plain prose only.";
const healed = healLoadedMessages(messagesToSummarize, DEFAULT_MAX_RESULT_CHARS).messages;
const agentSystem = this.deps.getSystemPrompt();
const fewShots = this.deps.getFewShots?.() ?? [];
const tools = this.deps.getToolSpecs?.() ?? [];
const instruction = buildFoldSummaryInstruction(pinnedSkillNames);
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "system", content: agentSystem },
...fewShots.map((m) => ({ ...m })),
...healed,
{
role: "user",
content:
"Summarize the conversation above as plain prose. This summary replaces the original turns to free context — make it self-contained.",
},
{ role: "user", content: instruction },
];
const turnSignal = this.deps.getAbortSignal();
const foldCtrl = new AbortController();
@@ -369,9 +398,9 @@ export class ContextManager {
this.deps.client.chat({
model: summaryModel,
messages,
tools: tools.length ? (tools as ToolSpec[]) : undefined,
signal: foldCtrl.signal,
thinking: thinkingModeForModel(summaryModel),
reasoningEffort: "high",
thinking: "disabled",
}),
abortPromise,
timeoutPromise,
-181
View File
@@ -1,181 +0,0 @@
/** Batches same-style runs into one SGR — per-cell escapes balloon 200x50 frames to 50KB+. */
import type { Cell, Frame, FrameRow } from "./types.js";
const ESC = "\u001b";
const RESET = `${ESC}[0m`;
interface Style {
fg?: string;
bg?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
inverse?: boolean;
href?: string;
}
function sameStyle(a: Style, b: Style): boolean {
return (
a.fg === b.fg &&
a.bg === b.bg &&
!!a.bold === !!b.bold &&
!!a.dim === !!b.dim &&
!!a.italic === !!b.italic &&
!!a.underline === !!b.underline &&
!!a.inverse === !!b.inverse &&
a.href === b.href
);
}
function fgEscape(color: string | undefined): string | null {
if (!color) return null;
const rgb = parseColor(color);
if (rgb) return `38;2;${rgb[0]};${rgb[1]};${rgb[2]}`;
const named = NAMED_FG[color.toLowerCase()];
if (named !== undefined) return String(named);
return null;
}
function bgEscape(color: string | undefined): string | null {
if (!color) return null;
const rgb = parseColor(color);
if (rgb) return `48;2;${rgb[0]};${rgb[1]};${rgb[2]}`;
const named = NAMED_BG[color.toLowerCase()];
if (named !== undefined) return String(named);
return null;
}
function parseColor(s: string): [number, number, number] | null {
if (!s.startsWith("#")) return null;
const hex = s.slice(1);
if (hex.length !== 6) return null;
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.slice(2, 4), 16);
const b = Number.parseInt(hex.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
return [r, g, b];
}
const NAMED_FG: Record<string, number> = {
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
gray: 90,
grey: 90,
brightred: 91,
brightgreen: 92,
brightyellow: 93,
brightblue: 94,
brightmagenta: 95,
brightcyan: 96,
brightwhite: 97,
};
const NAMED_BG: Record<string, number> = {
black: 40,
red: 41,
green: 42,
yellow: 43,
blue: 44,
magenta: 45,
cyan: 46,
white: 47,
gray: 100,
grey: 100,
};
function styleToAnsi(s: Style): string {
const codes: string[] = [];
if (s.bold) codes.push("1");
if (s.dim) codes.push("2");
if (s.italic) codes.push("3");
if (s.underline) codes.push("4");
if (s.inverse) codes.push("7");
const fg = fgEscape(s.fg);
if (fg) codes.push(fg);
const bg = bgEscape(s.bg);
if (bg) codes.push(bg);
if (codes.length === 0) return "";
return `${ESC}[${codes.join(";")}m`;
}
const EMPTY_STYLE: Style = {};
/** RESET at row end so styling never bleeds onto the next line. */
export function frameToAnsi(f: Frame, opts: { plain?: boolean } = {}): string {
const out: string[] = [];
for (let i = 0; i < f.rows.length; i++) {
out.push(rowToAnsi(f.rows[i]!, opts));
}
return out.join("\n");
}
function rowToAnsi(row: FrameRow, opts: { plain?: boolean }): string {
if (opts.plain) {
let s = "";
for (const c of row) {
if (c.tail) continue;
s += c.char;
}
return s;
}
let result = "";
let curStyle = EMPTY_STYLE;
let inHref = false;
let curHref: string | undefined;
for (const c of row) {
if (c.tail) continue; // tail cells contribute no visible output
const cellStyle: Style = {
fg: c.fg,
bg: c.bg,
bold: c.bold,
dim: c.dim,
italic: c.italic,
underline: c.underline,
inverse: c.inverse,
href: c.href,
};
// OSC-8 hyperlink open/close
if (cellStyle.href !== curHref) {
if (inHref) {
// close prior link
result += `${ESC}]8;;${ESC}\\`;
inHref = false;
}
if (cellStyle.href !== undefined) {
result += `${ESC}]8;;${cellStyle.href}${ESC}\\`;
inHref = true;
}
curHref = cellStyle.href;
}
// SGR styling — emit only when changed
if (!sameStyle(curStyle, cellStyle)) {
// Reset before applying new style so e.g. bold→non-bold works
// (some terminals don't have a "turn off bold" code reliably).
result += RESET;
result += styleToAnsi(cellStyle);
curStyle = cellStyle;
}
result += c.char;
}
if (inHref) result += `${ESC}]8;;${ESC}\\`;
result += RESET;
return result;
}
export function rowText(row: FrameRow): string {
let s = "";
for (const c of row) {
if (c.tail) continue;
s += c.char;
}
return s;
}
-195
View File
@@ -1,195 +0,0 @@
/** Pure primitives on Frame; every row's cells sum to exactly `Frame.width` (tests in tests/frame.test.ts lock this). */
import type { Cell, Frame, FrameRow, TextOpts } from "./types.js";
import { graphemeWidth, graphemes } from "./width.js";
/** Single space cell with no styling — the universal padding atom. */
const SPACE: Cell = { char: " ", width: 1 };
/** Tail half of a 2-wide grapheme — alignment only, no glyph. */
const TAIL: Cell = { char: "", width: 1, tail: true };
export function empty(width = 0): Frame {
return { width, rows: [] };
}
export function blank(width: number, height: number): Frame {
if (width <= 0 || height <= 0) return empty(Math.max(0, width));
const row: FrameRow = Object.freeze(Array.from({ length: width }, () => SPACE));
const rows: FrameRow[] = [];
for (let i = 0; i < height; i++) rows.push(row);
return { width, rows };
}
export function text(s: string, opts: TextOpts): Frame {
const { width, fg, bg, bold, dim, italic, underline, inverse, href } = opts;
if (width <= 0) return empty(0);
const styleOf = (g: string, w: 1 | 2): Cell => {
const base: Cell = { char: g, width: w };
if (fg !== undefined) base.fg = fg;
if (bg !== undefined) base.bg = bg;
if (bold) base.bold = true;
if (dim) base.dim = true;
if (italic) base.italic = true;
if (underline) base.underline = true;
if (inverse) base.inverse = true;
if (href !== undefined) base.href = href;
return base;
};
const rows: FrameRow[] = [];
const lines = s.split("\n");
for (const line of lines) {
if (line.length === 0) {
rows.push(padRowRight([], width));
continue;
}
let buf: Cell[] = [];
let bufWidth = 0;
for (const g of graphemes(line)) {
const w = graphemeWidth(g);
if (w === 0) continue; // combining mark / ZWJ — already part of prior cell
if (bufWidth + w > width) {
rows.push(padRowRight(buf, width - bufWidth));
buf = [];
bufWidth = 0;
}
buf.push(styleOf(g, w as 1 | 2));
if (w === 2) buf.push(TAIL);
bufWidth += w;
}
rows.push(padRowRight(buf, width - bufWidth));
}
return { width, rows };
}
function padRowRight(cells: Cell[], extraSpaces: number): FrameRow {
if (extraSpaces <= 0) return cells.slice();
const out = cells.slice();
for (let i = 0; i < extraSpaces; i++) out.push(SPACE);
return out;
}
/** Generate a row of pure-space padding at the given width. */
function spacerRow(width: number): FrameRow {
if (width <= 0) return [];
return Array.from({ length: width }, () => SPACE);
}
export function vstack(...frames: Frame[]): Frame {
if (frames.length === 0) return empty(0);
const w = Math.max(...frames.map((f) => f.width));
const rows: FrameRow[] = [];
for (const f of frames) {
if (f.width === w) {
rows.push(...f.rows);
} else {
const extra = w - f.width;
for (const r of f.rows) rows.push(padRowRight(r as Cell[], extra));
}
}
return { width: w, rows };
}
export function hstack(...frames: Frame[]): Frame {
if (frames.length === 0) return empty(0);
const h = Math.max(...frames.map((f) => f.rows.length));
const w = frames.reduce((a, f) => a + f.width, 0);
const rows: FrameRow[] = [];
for (let i = 0; i < h; i++) {
const cells: Cell[] = [];
for (const f of frames) {
const r = f.rows[i] ?? spacerRow(f.width);
cells.push(...r);
}
rows.push(cells);
}
return { width: w, rows };
}
/** Padding is in cells (visual columns), not graphemes. */
export function pad(f: Frame, top: number, right: number, bottom: number, left: number): Frame {
const newWidth = f.width + Math.max(0, left) + Math.max(0, right);
const tPad = Math.max(0, top);
const bPad = Math.max(0, bottom);
const blank = spacerRow(newWidth);
const rows: FrameRow[] = [];
for (let i = 0; i < tPad; i++) rows.push(blank);
if (left <= 0 && right <= 0) {
rows.push(...f.rows);
} else {
const lPad = spacerRow(Math.max(0, left));
const rPad = spacerRow(Math.max(0, right));
for (const r of f.rows) rows.push([...lPad, ...r, ...rPad]);
}
for (let i = 0; i < bPad; i++) rows.push(blank);
return { width: newWidth, rows };
}
export function borderLeft(f: Frame, color: string, char = "│"): Frame {
const bar: Cell = { char, width: 1, fg: color };
const newWidth = f.width + 1;
const rows: FrameRow[] = [];
for (const r of f.rows) rows.push([bar, ...r]);
return { width: newWidth, rows };
}
/** Out-of-range bounds clamp; never throws. */
export function slice(f: Frame, top: number, height: number): Frame {
if (height <= 0 || f.rows.length === 0) return { width: f.width, rows: [] };
const start = Math.max(0, Math.min(top, f.rows.length));
const end = Math.max(start, Math.min(start + height, f.rows.length));
return { width: f.width, rows: f.rows.slice(start, end) };
}
export function bottom(f: Frame, height: number): Frame {
if (height <= 0) return { width: f.width, rows: [] };
return slice(f, Math.max(0, f.rows.length - height), height);
}
/** `offset` counted from bottom; offset=0 is `bottom(f, height)`. Caps to a valid slice. */
export function viewport(f: Frame, offset: number, height: number): Frame {
if (height <= 0) return { width: f.width, rows: [] };
const maxOffset = Math.max(0, f.rows.length - height);
const o = Math.max(0, Math.min(offset, maxOffset));
const start = Math.max(0, f.rows.length - height - o);
return slice(f, start, height);
}
/** Result has SAME dimensions as `base` — overlay never grows the frame. */
export function overlay(base: Frame, top: Frame, x: number, y: number): Frame {
const rows: FrameRow[] = base.rows.map((r) => r.slice());
for (let i = 0; i < top.rows.length; i++) {
const targetRow = rows[y + i] as Cell[] | undefined;
if (!targetRow) continue;
const src = top.rows[i]!;
let col = x;
for (const cell of src) {
if (col >= 0 && col < base.width) targetRow[col] = cell;
col += 1;
}
}
return { width: base.width, rows };
}
/** Cut splitting a 2-wide grapheme replaces the orphaned head with a space — half-glyphs render unpredictably. */
export function fitWidth(f: Frame, width: number): Frame {
if (f.width === width) return f;
const rows: FrameRow[] = [];
for (const r of f.rows) {
if (r.length >= width) {
const cut = r.slice(0, width) as Cell[];
const last = cut[cut.length - 1];
if (last && last.width === 2 && !last.tail) {
// Cut splits a 2-wide grapheme — head kept, tail dropped.
// Replace the orphaned head with a space so the visual width
// matches the row count.
cut[cut.length - 1] = SPACE;
}
rows.push(cut);
} else {
rows.push(padRowRight(r as Cell[], width - r.length));
}
}
return { width, rows };
}
-17
View File
@@ -1,17 +0,0 @@
export type { Cell, Frame, FrameRow, TextOpts } from "./types.js";
export {
blank,
borderLeft,
bottom,
empty,
fitWidth,
hstack,
overlay,
pad,
slice,
text,
viewport,
vstack,
} from "./frame.js";
export { frameToAnsi, rowText } from "./ansi.js";
export { graphemeWidth, graphemes, stringWidth } from "./width.js";
-43
View File
@@ -1,43 +0,0 @@
/** Canonical grid: every row's cell array totals exactly `Frame.width` (counting `tail` cells for 2-wide chars). */
/** `width` is canonical — never re-derived from the character. ANSI lives only in ansi.ts paint. */
export interface Cell {
/** 2-wide chars emit a `tail: true, char: ""` follower so row.length === Frame.width invariant holds. */
char: string;
/** 1 for ASCII / Latin / most BMP. 2 for CJK / emoji / fullwidth. */
width: 1 | 2;
/** Sentinel for the second cell of a 2-wide grapheme. */
tail?: boolean;
/** Foreground color: hex `#rrggbb` or named ANSI ("red", "cyan"). */
fg?: string;
/** Background color: hex `#rrggbb` or named ANSI. */
bg?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
inverse?: boolean;
/** OSC-8 hyperlink target (cell renders as a clickable link). */
href?: string;
}
/** INVARIANT: `cells.reduce((a, c) => a + (c.tail ? 0 : c.width), 0) === Frame.width`. */
export type FrameRow = readonly Cell[];
export interface Frame {
readonly width: number;
readonly rows: readonly FrameRow[];
}
export interface TextOpts {
/** Wrap column. Mandatory — text without a budget is a rendering bug. */
width: number;
fg?: string;
bg?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
inverse?: boolean;
href?: string;
}
+79 -39
View File
@@ -46,6 +46,16 @@ export const EN: TranslationSchema = {
sessions: {
emptyHint:
"no saved sessions yet — run `reasonix chat` (sessions are auto-saved unless --no-session).",
listHeader: "Saved sessions (~/.reasonix/sessions/):",
inspectHint: "Inspect: reasonix sessions <name>",
resumeHint: "Resume: reasonix chat --session <name>",
noSession: 'no session named "{name}" (or it\u2019s empty).',
lookedAt: "looked at: {path}",
noIdleSessions: "no sessions idle \u2265{days} days. Nothing pruned.",
wouldPrune: "would prune {count} session(s) idle \u2265{days} days:",
dryRunHint: "re-run without --dry-run to actually delete.",
prunedCount: "pruned {count} session(s) idle \u2265{days} days:",
daysInvalid: "--days must be a positive integer (got {days}).",
},
ui: {
welcome: "Run `reasonix` any time to start chatting — your settings are remembered.",
@@ -157,10 +167,6 @@ export const EN: TranslationSchema = {
title: "copy / paste",
rows: [
{ key: "select text", text: "drag to select — terminal-native (no modifier needed)" },
{
key: "/copy",
text: "vim/tmux-style copy mode — works in SSH/mosh/tmux where drag-select can't extend past the viewport",
},
{
key: "copy",
text: "Ctrl+Shift+C (Win/Linux) · Cmd+C (macOS) — or auto-copy-on-select if your terminal does it",
@@ -255,9 +261,6 @@ export const EN: TranslationSchema = {
},
slash: {
help: { description: "show the full command reference" },
copy: {
description: "open vim/tmux-style copy mode — j/k navigate, v select, y yank to clipboard",
},
status: { description: "current model, flags, context, session" },
preset: {
description: "model bundle — auto escalates flash → pro, flash/pro lock",
@@ -424,8 +427,8 @@ export const EN: TranslationSchema = {
},
"search-engine": {
description:
"switch web search backend — mojeek (default, no deps), searxng (self-hosted), metaso (free 100/d), tavily (free 1000/mo), perplexity (AI-native), or exa (AI-native)",
argsHint: "<mojeek|searxng|metaso|tavily|perplexity|exa> [<key>]",
"switch web search backend — bing (default, works from CN without proxy), searxng (self-hosted), metaso (free 100/d), tavily (free 1000/mo), perplexity (AI-native), or exa (AI-native)",
argsHint: "<bing|searxng|metaso|tavily|perplexity|exa> [<key>]",
},
},
wizard: {
@@ -597,6 +600,8 @@ export const EN: TranslationSchema = {
verboseOn: "▸ verbose mode on — full reasoning + tool output",
verboseOff: "▸ verbose mode off — head/tail elision restored",
commandFailed: "! command failed",
steerInjected: "▸ steering queued — will be added after the current step",
steerCommandRejected: "▸ commands are disabled while steering a busy turn",
btwUsage: "▸ /btw <question> — ask a side question without polluting the conversation context.",
btwHeader: "≫ btw",
btwFailed: "/btw failed",
@@ -647,6 +652,8 @@ export const EN: TranslationSchema = {
editHistoryRevertFile: "/undo {id} {path} \u2192 revert just this file",
mcpFailed: "MCP {name} failed",
mcpWarn: "MCP {name} warn",
unknownTheme: "unknown theme: {name}\navailable: {choices}",
themeSaved: "theme saved: {name}\nactive on next launch: {active}",
},
hooks: {
head: "hook {tag} `{cmd}` {decision}{truncTag}",
@@ -705,6 +712,8 @@ export const EN: TranslationSchema = {
"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}",
concurrency429:
"DeepSeek concurrency limit hit (429): {inner}. The account has too many in-flight requests (cap: 500 for v4-pro, 2500 for v4-flash, summed across API keys account-wide). Usually means another Reasonix process is sharing the same key, or a parallel subagent fan-out overshot. Wait a few seconds and retry, reduce parallelism, or request a higher cap at https://platform.deepseek.com.",
deepseek5xxHead:
"DeepSeek service unavailable ({status}) — this is a DeepSeek-side problem, not Reasonix. Already retried 4× with backoff.",
deepseek5xxReachable:
@@ -1074,6 +1083,10 @@ export const EN: TranslationSchema = {
noArchives:
"no archived plans yet for this session — they auto-archive when every step is done",
archivedHeader: "Archived ({count}):",
evidencePending:
" ! evidence pending — current step needs verification/diff/checkpoint/manual evidence",
evidenceLine: " evidence {stepId}: {summary}",
archivedEvidenceLine: " evidence: {summary}",
replayNoSession:
"no session attached — `/replay` is per-session. Run `reasonix code` in a project to get a session.",
replayNoArchives:
@@ -1187,7 +1200,8 @@ export const EN: TranslationSchema = {
currentEngine: "Current web search engine: {engine}",
endpoint: "SearXNG endpoint: {url}",
usageHeader: "Usage:",
usageMojeek: " /search-engine mojeek use Mojeek (default, no external deps)",
usageBing:
" /search-engine bing use Bing (default, works from CN without proxy)",
usageSearxng: " /search-engine searxng use SearXNG at default endpoint",
usageSearxngUrl: " /search-engine searxng <url> use SearXNG at custom endpoint",
usageMetaso:
@@ -1302,6 +1316,8 @@ export const EN: TranslationSchema = {
"no $EDITOR / $VISUAL / $GIT_EDITOR set \u2014 export one (e.g. `export EDITOR=nano`) and retry",
editorExited: "editor exited with code {code}",
typeaheadStaged: "\u25b8 {count} line(s) staged \u00b7 esc recall",
steerPlaceholder: "type to steer the current task — commands are disabled while busy",
steerHint: "send — injected mid-turn",
},
pathConfirm: {
title: "Outside-sandbox path",
@@ -1323,6 +1339,12 @@ export const EN: TranslationSchema = {
pathLabel: "path",
sandboxLabel: "sandbox",
allowPrefixLabel: "prefix",
promptTitleRead: "Access path \u2014 read",
promptTitleWrite: "Access path \u2014 write",
actionAllowRead: "Allow read",
actionAllowWrite: "Allow write",
actionAlwaysAllow: "Always allow \u2014 {prefix}",
actionDeny: "Deny",
},
shellConfirm: {
title: "Shell command",
@@ -1347,6 +1369,11 @@ export const EN: TranslationSchema = {
waitLabel: "wait",
previewMore: "… {n} more line hidden — press esc, ask the model to split it",
previewMorePlural: "… {n} more lines hidden — press esc, ask the model to split it",
promptTitleRunCommand: "Run command",
promptTitleRunBackground: "Run background command",
actionRunOnce: "Run once",
actionAlwaysAllow: "Always allow \u2014 {prefix}",
actionDeny: "Deny",
},
editConfirm: {
footer:
@@ -1495,25 +1522,25 @@ export const EN: TranslationSchema = {
},
webErrors: {
status:
"web_search {status} \u2014 try: the search backend returned an error; rephrase the query, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
"web_search {status} \u2014 try: the search backend returned an error; rephrase the query, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
rateLimit429:
"web_search 429 \u2014 try: wait 10s before retrying, or rephrase the query; the search backend is rate-limiting this client",
forbidden403:
"web_search 403 \u2014 try: the search backend is blocking this client; switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa, or wait and retry later",
"web_search 403 \u2014 try: the search backend is blocking this client; switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa, or wait and retry later",
serverError5xx:
"web_search {status} \u2014 try: open the search URL in a browser; if it loads this is transient and a retry in 30s may help",
mojeekBlocked:
"web_search: Mojeek anti-bot page \u2014 rate-limited or blocked \u2014 try: wait 30s and retry, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
mojeekNoResults:
"web_search: 0 results but response doesn't look like a real empty page ({chars} chars, first 120: {preview}) \u2014 try: rephrase the query with simpler terms, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
bingBlocked:
"web_search: Bing anti-bot page \u2014 rate-limited or blocked \u2014 try: wait 30s and retry, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
bingNoResults:
"web_search: 0 results but response doesn't look like a real empty page ({chars} chars, first 120: {preview}) \u2014 try: rephrase the query with simpler terms, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
invalidEndpoint:
'web_search: invalid SearXNG endpoint "{endpoint}" \u2014 try: set a valid URL with /search-endpoint http://host:port',
endpointMustBeHttp:
"web_search: SearXNG endpoint must be http(s), got {protocol} \u2014 try: set a valid URL with /search-endpoint http://host:port",
cannotReach:
"web_search: Cannot reach SearXNG server at {endpoint} \u2014 try: install and start SearXNG (https://github.com/searxng/searxng, e.g. `docker run -d -p 8080:8080 searxng/searxng`), or switch to another engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
"web_search: Cannot reach SearXNG server at {endpoint} \u2014 try: install and start SearXNG (https://github.com/searxng/searxng, e.g. `docker run -d -p 8080:8080 searxng/searxng`), or switch to another engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
searxngNoResults:
"web_search: 0 results but SearXNG response doesn't look like an empty results page ({chars} chars) \u2014 try: rephrase the query with simpler terms, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
"web_search: 0 results but SearXNG response doesn't look like an empty results page ({chars} chars) \u2014 try: rephrase the query with simpler terms, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
metasoMissingKey:
"web_search: Metaso requires an API key \u2014 set METASO_API_KEY or configure one with /search-engine metaso <key>. Get one at https://metaso.cn/search-api/playground",
metasoDailyLimit:
@@ -1523,7 +1550,7 @@ export const EN: TranslationSchema = {
metasoRateLimit:
"web_search: Metaso rate-limited \u2014 wait and retry, or get your own API key at https://metaso.cn/search-api/playground",
metasoServerError:
"web_search: Metaso server error ({status}) \u2014 try again later, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
"web_search: Metaso server error ({status}) \u2014 try again later, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
metasoParseError:
"web_search: Metaso returned unparseable response (HTTP {status}) \u2014 try again later",
metasoApiError: "web_search: Metaso API error (code {code}: {message}) \u2014 try again later",
@@ -1532,9 +1559,9 @@ export const EN: TranslationSchema = {
tavilyUnauthorized:
"web_search: Tavily API key rejected \u2014 check TAVILY_API_KEY or get one at https://tavily.com",
tavilyRateLimit:
"web_search: Tavily rate-limited or monthly quota exceeded \u2014 wait, switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa, or upgrade your Tavily plan",
"web_search: Tavily rate-limited or monthly quota exceeded \u2014 wait, switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa, or upgrade your Tavily plan",
tavilyServerError:
"web_search: Tavily server error ({status}) \u2014 try again later, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
"web_search: Tavily server error ({status}) \u2014 try again later, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
tavilyParseError:
"web_search: Tavily returned unparseable response (HTTP {status}) \u2014 try again later",
perplexityMissingKey:
@@ -1542,9 +1569,9 @@ export const EN: TranslationSchema = {
perplexityUnauthorized:
"web_search: Perplexity API key rejected \u2014 check PERPLEXITY_API_KEY or get one at https://perplexity.ai/settings/api",
perplexityRateLimit:
"web_search: Perplexity rate-limited \u2014 wait and retry, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
"web_search: Perplexity rate-limited \u2014 wait and retry, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
perplexityServerError:
"web_search: Perplexity server error ({status}) \u2014 try again later, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
"web_search: Perplexity server error ({status}) \u2014 try again later, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
perplexityParseError:
"web_search: Perplexity returned unparseable response (HTTP {status}) \u2014 try again later",
exaMissingKey:
@@ -1554,7 +1581,7 @@ export const EN: TranslationSchema = {
exaRateLimit:
"web_search: Exa API rate-limited or monthly quota exceeded \u2014 wait or upgrade at https://exa.ai/pricing",
exaServerError:
"web_search: Exa server error ({status}) \u2014 try again later, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
"web_search: Exa server error ({status}) \u2014 try again later, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
exaParseError:
"web_search: Exa returned unparseable response (HTTP {status}) \u2014 try again later",
fetchStatus:
@@ -1656,19 +1683,6 @@ export const EN: TranslationSchema = {
categoryProject: "project",
categoryReference: "reference",
},
copyMode: {
title: "── COPY MODE ──",
help: "j/k or ↑/↓ move · v select · y yank · g/G top/bottom · q quit",
statusBar: "line {cur}/{total} · selection: {sel}",
statusYanked: "yanked {size} chars (osc52={osc52})",
statusEmpty: "nothing selected",
empty: "(no chat content yet — say something to the model first)",
labelUser: "you",
labelAssistant: "assistant",
labelReasoning: "reasoning",
yankedToast: "▸ copied {size} chars to clipboard (osc52)",
yankedToastFile: "▸ copied {size} chars · file: {path}",
},
mcpHealth: {
noData: "no inspect data",
healthy: "healthy \u00b7 {ms}ms",
@@ -1834,9 +1848,11 @@ export const EN: TranslationSchema = {
groupSystem: "System",
descEnter: "Send message",
descShiftEnter: "New line",
descCtrlEnter: "New line",
descCtrlJ: "New line",
descCtrlU: "Clear input",
descCtrlW: "Delete word",
descCtrlP: "Toggle shortcut panel",
descCtrlP: "Show/hide shortcuts",
descCtrlX: "Open in editor",
descArrows: "Input history",
descPgUpDown: "Scroll page",
@@ -1850,8 +1866,32 @@ export const EN: TranslationSchema = {
descCtrlC: "Quit",
descEsc: "Stop / Cancel",
descCtrlR: "Toggle verbose",
descCtrlO: "Expand stream",
descCtrlO: "Expand reply (streaming only)",
descHelp: "Show all commands",
descShiftTab: "Switch edit mode",
},
mcpCli: {
bundledCatalog: "Bundled MCP servers (offline catalog):",
justFetched: "just fetched",
cachedAge: "cached, {age}",
moreAvailable: "more available",
allLoaded: "all loaded",
morePagesAvailable:
"\u25b8 more pages available \u2014 `reasonix mcp list --pages <n>` or --all",
installHint: "Install: reasonix mcp install <name>",
usageSearch: "usage: reasonix mcp search <query>",
usageInstall: "usage: reasonix mcp install <name>",
noMatchesFor: 'No matches for "{q}" across {count} loaded entries ({source})',
matchCount: '{count} match(es) for "{q}" in {source} registry ({loaded} entries scanned):',
moreLoaded: "\u2026 {count} more loaded \u2014 use `reasonix mcp search <query>` to filter",
moreMatches: "\u2026 {count} more matches",
installed: "Installed: {spec}",
noServerFound:
'No MCP server named "{target}" found after walking {pages} page(s) of the {source} registry.',
noServerTryMore: "Try: reasonix mcp install {target} --max-pages 100",
noInstallMeta:
'Could not derive install metadata for "{name}" \u2014 try `npx -y @smithery/cli install {name}` directly.',
buildSpecFailed: "Cannot build install spec for {name}: {message}",
alreadyInstalled: "Already installed: {spec}",
},
};
+55 -15
View File
@@ -43,6 +43,16 @@ export interface TranslationSchema {
};
sessions: {
emptyHint: string;
listHeader: string;
inspectHint: string;
resumeHint: string;
noSession: string;
lookedAt: string;
noIdleSessions: string;
wouldPrune: string;
dryRunHint: string;
prunedCount: string;
daysInvalid: string;
};
ui: {
welcome: string;
@@ -187,6 +197,8 @@ export interface TranslationSchema {
verboseOn: string;
verboseOff: string;
commandFailed: string;
steerInjected: string;
steerCommandRejected: string;
btwUsage: string;
btwHeader: string;
btwFailed: string;
@@ -233,6 +245,8 @@ export interface TranslationSchema {
editHistoryRevertFile: string;
mcpFailed: string;
mcpWarn: string;
unknownTheme: string;
themeSaved: string;
};
mcpBrowse: {
noResources: string;
@@ -288,6 +302,7 @@ export interface TranslationSchema {
balance402: string;
badparam422: string;
badrequest400: string;
concurrency429: string;
deepseek5xxHead: string;
deepseek5xxReachable: string;
deepseek5xxUnreachable: string;
@@ -470,6 +485,10 @@ export interface TranslationSchema {
editorExited: string;
/** Typeahead queue indicator, e.g. "▸ 3 lines staged · esc recall" */
typeaheadStaged: string;
/** Placeholder shown when steerBusy is active. */
steerPlaceholder: string;
/** Status-line hint shown when steerBusy is active. */
steerHint: string;
};
pathConfirm: {
title: string;
@@ -489,6 +508,12 @@ export interface TranslationSchema {
pathLabel: string;
sandboxLabel: string;
allowPrefixLabel: string;
promptTitleRead: string;
promptTitleWrite: string;
actionAllowRead: string;
actionAllowWrite: string;
actionAlwaysAllow: string;
actionDeny: string;
};
shellConfirm: {
title: string;
@@ -511,6 +536,11 @@ export interface TranslationSchema {
waitLabel: string;
previewMore: string;
previewMorePlural: string;
promptTitleRunCommand: string;
promptTitleRunBackground: string;
actionRunOnce: string;
actionAlwaysAllow: string;
actionDeny: string;
};
editConfirm: {
footer: string;
@@ -655,8 +685,8 @@ export interface TranslationSchema {
rateLimit429: string;
forbidden403: string;
serverError5xx: string;
mojeekBlocked: string;
mojeekNoResults: string;
bingBlocked: string;
bingNoResults: string;
invalidEndpoint: string;
endpointMustBeHttp: string;
cannotReach: string;
@@ -773,19 +803,6 @@ export interface TranslationSchema {
categoryProject: string;
categoryReference: string;
};
copyMode: {
title: string;
help: string;
statusBar: string;
statusYanked: string;
statusEmpty: string;
empty: string;
labelUser: string;
labelAssistant: string;
labelReasoning: string;
yankedToast: string;
yankedToastFile: string;
};
mcpHealth: {
noData: string;
healthy: string;
@@ -925,6 +942,8 @@ export interface TranslationSchema {
groupSystem: string;
descEnter: string;
descShiftEnter: string;
descCtrlEnter: string;
descCtrlJ: string;
descCtrlU: string;
descCtrlW: string;
descCtrlP: string;
@@ -945,4 +964,25 @@ export interface TranslationSchema {
descHelp: string;
descShiftTab: string;
};
mcpCli: {
bundledCatalog: string;
justFetched: string;
cachedAge: string;
moreAvailable: string;
allLoaded: string;
morePagesAvailable: string;
installHint: string;
usageSearch: string;
usageInstall: string;
noMatchesFor: string;
matchCount: string;
moreLoaded: string;
moreMatches: string;
installed: string;
noServerFound: string;
noServerTryMore: string;
noInstallMeta: string;
buildSpecFailed: string;
alreadyInstalled: string;
};
}
+75 -39
View File
@@ -46,6 +46,16 @@ export const zhCN: TranslationSchema = {
sessions: {
emptyHint:
"暂无已保存的会话 — 运行 `reasonix chat`(会话会自动保存,除非使用了 --no-session)。",
listHeader: "保存的会话 (~/.reasonix/sessions/):",
inspectHint: "查看:reasonix sessions <name>",
resumeHint: "恢复:reasonix chat --session <name>",
noSession: '找不到会话 "{name}"(或为空)。',
lookedAt: "位置:{path}",
noIdleSessions: "没有闲置 ≥{days} 天的会话。无需清理。",
wouldPrune: "将清理 {count} 个闲置 ≥{days} 天的会话:",
dryRunHint: "去掉 --dry-run 可实际执行删除。",
prunedCount: "已清理 {count} 个闲置 ≥{days} 天的会话:",
daysInvalid: "--days 必须是正整数(传入了 {days})。",
},
ui: {
welcome: "随时运行 `reasonix` 开始聊天 — 您的设置将被记住。",
@@ -150,10 +160,6 @@ export const zhCN: TranslationSchema = {
title: "复制 / 粘贴",
rows: [
{ key: "选中文字", text: "直接拖动 — 终端原生(不需要任何修饰键)" },
{
key: "/copy",
text: "vim/tmux 风格复制模式 — SSH / mosh / tmux 下拖选越过可视区无效时用这个",
},
{
key: "复制",
text: "Ctrl+Shift+C(Win/Linux)· Cmd+C(macOS)— 或选中即复制(看终端设置)",
@@ -246,9 +252,6 @@ export const zhCN: TranslationSchema = {
},
slash: {
help: { description: "显示完整命令参考" },
copy: {
description: "进入 vim/tmux 风格复制模式 — j/k 移动、v 起选区、y 复制到剪贴板",
},
status: { description: "当前模型、标志、上下文、会话" },
preset: {
description: "模型组合 — 自动在 flash → pro 之间切换,或锁定 flash/pro",
@@ -409,8 +412,8 @@ export const zhCN: TranslationSchema = {
},
"search-engine": {
description:
"切换网络搜索后端 — mojeek(默认,无依赖)、searxng(自托管)、metaso(每日 100 次)、tavily(每月 1000 次免费)、perplexity(AI 直接回答)或 exa(AI 直接回答)",
argsHint: "<mojeek|searxng|metaso|tavily|perplexity|exa> [<key>]",
"切换网络搜索后端 — bing(默认,国内裸 IP 直连)、searxng(自托管)、metaso(每日 100 次)、tavily(每月 1000 次免费)、perplexity(AI 直接回答)或 exa(AI 直接回答)",
argsHint: "<bing|searxng|metaso|tavily|perplexity|exa> [<key>]",
},
},
wizard: {
@@ -579,6 +582,8 @@ export const zhCN: TranslationSchema = {
verboseOn: "▸ 详细模式已开 — 显示完整推理 + 工具输出",
verboseOff: "▸ 详细模式已关 — 恢复头尾省略",
commandFailed: "! 命令失败",
steerInjected: "▸ 已加入引导队列 — 将在当前步骤后注入",
steerCommandRejected: "▸ 当前轮次忙碌时不能提交命令,只能输入普通引导消息",
btwUsage: "▸ /btw <问题> — 顺便问个题外话,不会写入当前会话上下文。",
btwHeader: "≫ btw",
btwFailed: "/btw 调用失败",
@@ -627,6 +632,8 @@ export const zhCN: TranslationSchema = {
editHistoryRevertFile: "/undo {id} {path} → 仅还原此文件",
mcpFailed: "MCP {name} 失败",
mcpWarn: "MCP {name} 警告",
unknownTheme: "未知主题:{name}\n可用主题:{choices}",
themeSaved: "主题已保存:{name}\n下次启动生效:{active}",
},
hooks: {
head: "钩子 {tag} `{cmd}` {decision}{truncTag}",
@@ -683,6 +690,8 @@ export const zhCN: TranslationSchema = {
"余额不足(DeepSeek 402):{inner}。在 https://platform.deepseek.com/top_up 充值 — 余额非零时面板顶栏会显示。",
badparam422: "参数错误(DeepSeek 422):{inner}",
badrequest400: "请求错误(DeepSeek 400):{inner}",
concurrency429:
"DeepSeek 并发超限(429):{inner}。账号在跑的请求超过上限(v4-pro 500、v4-flash 2500,账号下所有 API key 累加)。通常是同一账号开了多个 Reasonix 进程,或者并行 subagent 一次发太多。等几秒重试、减少并行,或在 https://platform.deepseek.com 申请扩容。",
deepseek5xxHead:
"DeepSeek 服务不可用({status}) — 这是 DeepSeek 服务端问题,不是 Reasonix 故障。已按指数退避重试 4 次。",
deepseek5xxReachable:
@@ -1013,6 +1022,10 @@ export const zhCN: TranslationSchema = {
activeNone: "▸ 活跃计划:(无)",
noArchives: "此会话尚无归档计划 — 当每个步骤完成时自动归档",
archivedHeader: "已归档({count}):",
evidencePending:
" ! 等待 evidence — 当前步骤需要 verification/diff/checkpoint/manual evidence",
evidenceLine: " evidence {stepId}: {summary}",
archivedEvidenceLine: " evidence: {summary}",
replayNoSession:
"未附加会话 — `/replay` 是按会话的。在项目中运行 `reasonix code` 以获取会话。",
replayNoArchives:
@@ -1122,7 +1135,7 @@ export const zhCN: TranslationSchema = {
currentEngine: "当前网页搜索引擎:{engine}",
endpoint: "SearXNG 端点:{url}",
usageHeader: "用法:",
usageMojeek: " /search-engine mojeek 使用 Mojeek(默认,无外部依赖)",
usageBing: " /search-engine bing 使用 Bing(默认,国内裸 IP 直连,无需代理)",
usageSearxng: " /search-engine searxng 使用 SearXNG 默认端点",
usageSearxngUrl: " /search-engine searxng <url> 使用 SearXNG 自定义端点",
usageMetaso:
@@ -1232,6 +1245,8 @@ export const zhCN: TranslationSchema = {
"未设置 $EDITOR / $VISUAL / $GIT_EDITOR — 请导出环境变量(例如 `export EDITOR=nano`)后重试",
editorExited: "编辑器异常退出,返回码 {code}",
typeaheadStaged: "\u25b8 {count} 行已暂存 \u00b7 esc 召回",
steerPlaceholder: "输入消息以引导当前任务 — 忙碌时不支持命令",
steerHint: "发送 — 回合内注入",
},
pathConfirm: {
title: "沙箱外路径",
@@ -1251,6 +1266,12 @@ export const zhCN: TranslationSchema = {
pathLabel: "路径",
sandboxLabel: "沙箱",
allowPrefixLabel: "前缀",
promptTitleRead: "访问路径 — 读取",
promptTitleWrite: "访问路径 — 写入",
actionAllowRead: "允许读取",
actionAllowWrite: "允许写入",
actionAlwaysAllow: "始终允许 — {prefix}",
actionDeny: "拒绝",
},
shellConfirm: {
title: "Shell 命令",
@@ -1273,6 +1294,11 @@ export const zhCN: TranslationSchema = {
waitLabel: "等待",
previewMore: "… 还有 {n} 行未显示 — 按 esc 取消,让模型拆分后再试",
previewMorePlural: "… 还有 {n} 行未显示 — 按 esc 取消,让模型拆分后再试",
promptTitleRunCommand: "运行命令",
promptTitleRunBackground: "运行后台命令",
actionRunOnce: "运行一次",
actionAlwaysAllow: "始终允许 — {prefix}",
actionDeny: "拒绝",
},
editConfirm: {
footer:
@@ -1416,25 +1442,25 @@ export const zhCN: TranslationSchema = {
},
webErrors: {
status:
"web_search {status} — try: 搜索后端返回错误;请改写查询,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
"web_search {status} — try: 搜索后端返回错误;请改写查询,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
rateLimit429:
"web_search 429 — try: 等待 10 秒后重试,或改写查询;搜索后端正在对该客户端进行限流",
forbidden403:
"web_search 403 — try: 搜索后端拒绝该客户端访问;使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎,或稍后重试",
"web_search 403 — try: 搜索后端拒绝该客户端访问;使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎,或稍后重试",
serverError5xx:
"web_search {status} — try: 在浏览器中打开搜索 URL;若能加载则属临时故障,等 30 秒重试即可",
mojeekBlocked:
"web_search: Mojeek 反爬页面 — 频率限制或被屏蔽 — try: 等待 30 秒后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
mojeekNoResults:
"web_search: 返回 0 条结果但响应看起来不是正常空结果页({chars} 字符,前 120 字符:{preview})— try: 使用更简单的关键词改写查询,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
bingBlocked:
"web_search: Bing 反爬页面 — 频率限制或被屏蔽 — try: 等待 30 秒后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
bingNoResults:
"web_search: 返回 0 条结果但响应看起来不是正常空结果页({chars} 字符,前 120 字符:{preview})— try: 使用更简单的关键词改写查询,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
invalidEndpoint:
'web_search: 无效的 SearXNG 端点 "{endpoint}" — try: 使用 /search-endpoint http://host:port 设置有效的 URL',
endpointMustBeHttp:
"web_search: SearXNG 端点必须是 http(s) 协议,当前为 {protocol} — try: 使用 /search-endpoint http://host:port 设置有效的 URL",
cannotReach:
"web_search: 无法访问 SearXNG 服务器 {endpoint} — try: 安装并启动 SearXNG(https://github.com/searxng/searxng,例如 `docker run -d -p 8080:8080 searxng/searxng`),或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
"web_search: 无法访问 SearXNG 服务器 {endpoint} — try: 安装并启动 SearXNG(https://github.com/searxng/searxng,例如 `docker run -d -p 8080:8080 searxng/searxng`),或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
searxngNoResults:
"web_search: 返回 0 条结果但 SearXNG 响应看起来不是正常空结果页({chars} 字符)— try: 使用更简单的关键词改写查询,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
"web_search: 返回 0 条结果但 SearXNG 响应看起来不是正常空结果页({chars} 字符)— try: 使用更简单的关键词改写查询,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
metasoMissingKey:
"web_search: Metaso 需要 API 密钥 — 设置 METASO_API_KEY,或使用 /search-engine metaso <key> 配置;可在 https://metaso.cn/search-api/playground 获取密钥",
metasoDailyLimit:
@@ -1444,7 +1470,7 @@ export const zhCN: TranslationSchema = {
metasoRateLimit:
"web_search: Metaso 请求频率限制 — 等待后重试,或在 https://metaso.cn/search-api/playground 获取自己的密钥",
metasoServerError:
"web_search: Metaso 服务器错误({status})— 稍后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
"web_search: Metaso 服务器错误({status})— 稍后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
metasoParseError: "web_search: Metaso 返回无法解析的响应(HTTP {status})— 稍后重试",
metasoApiError: "web_search: Metaso API 错误(code {code}: {message})— 稍后重试",
tavilyMissingKey:
@@ -1452,18 +1478,18 @@ export const zhCN: TranslationSchema = {
tavilyUnauthorized:
"web_search: Tavily API 密钥被拒绝 — 检查 TAVILY_API_KEY,或在 https://tavily.com 获取密钥",
tavilyRateLimit:
"web_search: Tavily 请求频率限制或月度配额用尽 — 等待、用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎,或升级 Tavily 计划",
"web_search: Tavily 请求频率限制或月度配额用尽 — 等待、用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎,或升级 Tavily 计划",
tavilyServerError:
"web_search: Tavily 服务器错误({status})— 稍后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
"web_search: Tavily 服务器错误({status})— 稍后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
tavilyParseError: "web_search: Tavily 返回无法解析的响应(HTTP {status})— 稍后重试",
perplexityMissingKey:
"web_search: Perplexity 后端需要 API 密钥 — 设置 PERPLEXITY_API_KEY 环境变量,或在 ~/.reasonix/config.json 中配置 `perplexityApiKey`;在 https://perplexity.ai/settings/api 获取密钥",
perplexityUnauthorized:
"web_search: Perplexity API 密钥被拒绝 — 检查 PERPLEXITY_API_KEY,或在 https://perplexity.ai/settings/api 获取密钥",
perplexityRateLimit:
"web_search: Perplexity 请求频率限制 — 等待后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
"web_search: Perplexity 请求频率限制 — 等待后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
perplexityServerError:
"web_search: Perplexity 服务器错误({status})— 稍后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
"web_search: Perplexity 服务器错误({status})— 稍后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
perplexityParseError: "web_search: Perplexity 返回无法解析的响应(HTTP {status})— 稍后重试",
exaMissingKey:
"web_search: Exa 后端需要 API 密钥 — 设置 EXA_API_KEY 环境变量,或在 ~/.reasonix/config.json 中配置 `exaApiKey`;https://exa.ai 每月 1000 次免费",
@@ -1472,7 +1498,7 @@ export const zhCN: TranslationSchema = {
exaRateLimit:
"web_search: Exa 请求频率限制或月度配额用尽 — 等待升级,或在 https://exa.ai/pricing 查看计划",
exaServerError:
"web_search: Exa 服务器错误({status})— 稍后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
"web_search: Exa 服务器错误({status})— 稍后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
exaParseError: "web_search: Exa 返回无法解析的响应(HTTP {status})— 稍后重试",
fetchStatus:
"web_fetch {status} for {url} — try: 在浏览器中确认该 URL 能否访问;该状态码表明目标主机返回了错误页面",
@@ -1572,19 +1598,6 @@ export const zhCN: TranslationSchema = {
categoryProject: "项目",
categoryReference: "参考",
},
copyMode: {
title: "── 复制模式 ──",
help: "j/k 或 ↑/↓ 移动 · v 起选区 · y 复制 · g/G 顶/底 · q 退出",
statusBar: "第 {cur}/{total} 行 · 选区:{sel}",
statusYanked: "已复制 {size} 字符(osc52={osc52})",
statusEmpty: "未选中内容",
empty: "(还没有聊天内容 — 先和模型说点什么)",
labelUser: "你",
labelAssistant: "助手",
labelReasoning: "推理",
yankedToast: "▸ 已复制 {size} 字符到剪贴板 (osc52)",
yankedToastFile: "▸ 已复制 {size} 字符 · 文件:{path}",
},
mcpHealth: {
noData: "无检查数据",
healthy: "正常 \u00b7 {ms}ms",
@@ -1737,9 +1750,11 @@ export const zhCN: TranslationSchema = {
groupSystem: "系统",
descEnter: "发送消息",
descShiftEnter: "换行",
descCtrlEnter: "换行",
descCtrlJ: "换行",
descCtrlU: "清空输入",
descCtrlW: "删除单词",
descCtrlP: "打开/关闭快捷键面板",
descCtrlP: "显示/隐藏快捷键",
descCtrlX: "在编辑器中打开",
descArrows: "浏览输入历史",
descPgUpDown: "翻页",
@@ -1753,8 +1768,29 @@ export const zhCN: TranslationSchema = {
descCtrlC: "退出",
descEsc: "停止/取消",
descCtrlR: "切换详细模式",
descCtrlO: "展开流式输出",
descCtrlO: "展开回复(仅流式输出期间)",
descHelp: "显示所有命令",
descShiftTab: "切换编辑模式",
},
mcpCli: {
bundledCatalog: "已打包的 MCP 服务器(离线目录):",
justFetched: "刚刚获取",
cachedAge: "缓存,{age}",
moreAvailable: "还有更多",
allLoaded: "已全部加载",
morePagesAvailable: "▸ 还有更多页可用 — `reasonix mcp list --pages <n>` 或 --all",
installHint: "安装:reasonix mcp install <name>",
usageSearch: "用法:reasonix mcp search <query>",
usageInstall: "用法:reasonix mcp install <name>",
noMatchesFor: '未找到 "{q}" 的匹配项(已检索 {count} 条记录,来源:{source})',
matchCount: '在 {source} 中找到 {count} 条 "{q}" 的匹配项(已扫描 {loaded} 条记录):',
moreLoaded: "… 还有 {count} 条已加载 — 使用 `reasonix mcp search <query>` 筛选",
moreMatches: "… 还有 {count} 条匹配项",
installed: "已安装:{spec}",
noServerFound: '在 {source} 中遍历了 {pages} 页后未找到名为 "{target}" 的 MCP 服务器。',
noServerTryMore: "试试:reasonix mcp install {target} --max-pages 100",
noInstallMeta: '无法为 "{name}" 获取安装元数据 — 试试 `npx -y @smithery/cli install {name}`。',
buildSpecFailed: "无法为 {name} 构建安装 spec:{message}",
alreadyInstalled: "已安装:{spec}",
},
};
+1 -1
View File
@@ -132,7 +132,7 @@ export type { RunCommandResult, ShellToolsOptions } from "./tools/shell.js";
export {
formatSearchResults,
htmlToText,
parseMojeekResults,
parseBingResults,
parseSearxngHtmlResults,
registerWebTools,
webFetch,
+60 -26
View File
@@ -51,9 +51,17 @@ import {
import { type RepairReport, ToolCallRepair } from "./repair/index.js";
import { SessionStats, type TurnStats } from "./telemetry/stats.js";
import { ToolRegistry } from "./tools.js";
import { parseRateLimitedToolResult } from "./tools/rate-limit.js";
import { ReadTracker } from "./tools/read-tracker.js";
import type { ChatMessage, ToolCall } from "./types.js";
const ESCALATION_MODEL = "deepseek-v4-pro";
export const MID_TURN_STEER_WRAPPER =
"[Mid-turn steer queued by the user. Do not treat this as a new task; use it only as additional guidance for the current task after completing the current step.]";
function formatSteerUserMessage(content: string): string {
return [MID_TURN_STEER_WRAPPER, content].join("\n");
}
export {
fixToolCallPairing,
@@ -109,6 +117,8 @@ export class CacheFirstLoop {
readonly scratch = new VolatileScratch();
readonly stats = new SessionStats();
readonly repair: ToolCallRepair;
/** Files the model has read this session; gates edit_file / multi_edit so SEARCH text matches on-disk bytes. Cleared on fold / mechanical truncate (the model's byte-level view of the elided history is gone). In-memory only — naturally empty on resume. */
readonly readTracker = new ReadTracker();
// Mutable via configure() — slash commands in the TUI / library callers tweak
// these mid-session so users don't have to restart.
@@ -139,17 +149,21 @@ export class CacheFirstLoop {
/** Authoritative running-id set — UI cards consult this instead of trusting end-event delivery. Insert at dispatch entry, delete in finally. */
private readonly _inflight = new InflightSet();
/** Typeahead steer message set by the UI; step() consumes it at the next iter boundary. */
private _steer: string | null = null;
/** Typeahead steer messages set by the UI; step() consumes one at each iter boundary. */
private readonly _steerQueue: string[] = [];
/** Set true when a steer was consumed this turn; cleared on next step() entry. */
private _steerConsumed = false;
/** UI calls this to inject a mid-turn steer message without aborting the current turn.
* New text resets steerConsumed — a fresh steer hasn't been consumed yet. */
* New text resets steerConsumed because a fresh steer is queued. */
steer(text: string | null): void {
this._steer = text;
if (text !== null) this._steerConsumed = false;
if (text === null) {
this._steerQueue.length = 0;
return;
}
this._steerQueue.push(text);
this._steerConsumed = false;
}
/** True when a steer was consumed this turn (UI gate to avoid double-submit). */
@@ -253,6 +267,10 @@ export class CacheFirstLoop {
sessionName: this.sessionName,
getAbortSignal: () => this._turnAbort.signal,
getCurrentTurn: () => this._turn,
getSystemPrompt: () => this.prefix.system,
getToolSpecs: () => this.prefix.toolSpecs,
getFewShots: () => this.prefix.fewShots,
onLogRewrite: () => this.readTracker.reset(),
});
}
@@ -459,6 +477,7 @@ export class CacheFirstLoop {
signal,
maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
confirmationGate: this.confirmationGate,
readTracker: this.readTracker,
});
const postReport = await runHooks({
@@ -490,11 +509,9 @@ export class CacheFirstLoop {
}
private _inflightCounter = 0;
private buildMessages(pendingUser: string | null): ChatMessage[] {
private buildMessages(): ChatMessage[] {
const healedMessages = this.healActiveLogBeforeSend();
const msgs: ChatMessage[] = [...this.prefix.toMessages(), ...healedMessages];
if (pendingUser !== null) msgs.push({ role: "user", content: pendingUser });
return msgs;
return [...this.prefix.toMessages(), ...healedMessages];
}
private healActiveLogBeforeSend(): ChatMessage[] {
@@ -589,6 +606,7 @@ export class CacheFirstLoop {
cap: this.budgetUsd.toFixed(2),
}),
};
this._steerQueue.length = 0;
return;
}
if (!this._budgetWarned && spent >= this.budgetUsd * 0.8) {
@@ -653,8 +671,8 @@ export class CacheFirstLoop {
// first round-trip still leaves the message in the log; the user can
// /retry without re-typing.
this.appendAndPersist({ role: "user", content: userInput });
let pendingUser: string | null = null;
const toolSpecs = this.prefix.tools();
let rateLimitWarningShown = false;
for (let iter = 0; ; iter++) {
if (signal.aborted) {
@@ -682,6 +700,7 @@ export class CacheFirstLoop {
} finally {
this._turnAbort = new AbortController();
}
this._steerQueue.length = 0;
return;
}
// Bridge the silence between the PREVIOUS iter's tool result and
@@ -703,20 +722,16 @@ export class CacheFirstLoop {
content: t("loop.toolUploadStatus"),
};
}
let messages = this.buildMessages(pendingUser);
let messages = this.buildMessages();
// Consume a typeahead steer if the UI wrote one between iters.
// Injecting as a user message via appendAndPersist means the
// next buildMessages() (or the fold rebuild below) will include it.
if (this._steer !== null) {
const steer = this._steer;
this._steer = null;
this._steerConsumed = true;
this.appendAndPersist({ role: "user", content: steer });
messages = this.buildMessages(pendingUser);
// Treat the steer as a fresh user utterance — reset pendingUser
// since it's already in the log now.
pendingUser = null;
if (this._steerQueue.length > 0) {
const steer = this._steerQueue.shift()!;
this._steerConsumed = this._steerQueue.length === 0;
this.appendAndPersist({
role: "user",
content: formatSteerUserMessage(steer),
});
messages = this.buildMessages();
yield {
turn: this._turn,
role: "steer",
@@ -738,10 +753,10 @@ export class CacheFirstLoop {
content: t("loop.preflightTruncateStatus"),
};
const result = this.context.mechanicalTruncate(this.model, {
allowEmpty: pendingUser !== null,
allowEmpty: false,
});
if (result.folded) {
messages = this.buildMessages(pendingUser);
messages = this.buildMessages();
const after = this.context.decidePreflight(messages, this.prefix.toolSpecs, this.model);
const stillFull = after.needsAction;
yield {
@@ -941,6 +956,7 @@ export class CacheFirstLoop {
} finally {
this._turnAbort = new AbortController();
}
this._steerQueue.length = 0;
return;
}
const probe = is5xxError(err) ? await probeDeepSeekReachable(this.client) : undefined;
@@ -950,6 +966,7 @@ export class CacheFirstLoop {
content: "",
error: formatLoopError(err as Error, probe),
};
this._steerQueue.length = 0;
return;
}
@@ -1069,11 +1086,16 @@ export class CacheFirstLoop {
}
if (repairedCalls.length === 0) {
if (this._steerQueue.length > 0) {
continue;
}
if (allSuppressed) {
yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "stuck" });
this._steerQueue.length = 0;
return;
}
yield { turn: this._turn, role: "done", content: assistantContent };
this._steerQueue.length = 0;
return;
}
@@ -1122,6 +1144,7 @@ export class CacheFirstLoop {
};
this.context.trimTrailingToolCalls();
yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "context-guard" });
this._steerQueue.length = 0;
return;
}
@@ -1196,6 +1219,17 @@ export class CacheFirstLoop {
for (const w of preWarnings) yield w;
for (const w of postWarnings) yield w;
// Keep the structured result in history; the warning is only host-side visibility.
const rateLimited = parseRateLimitedToolResult(result);
if (rateLimited && !rateLimitWarningShown) {
rateLimitWarningShown = true;
yield {
turn: this._turn,
role: "warning",
content: rateLimited.message,
};
}
this.appendAndPersist({
role: "tool",
tool_call_id: call.id ?? "",
@@ -1224,7 +1258,7 @@ export class CacheFirstLoop {
return {
client: this.client,
signal: this._turnAbort.signal,
buildMessages: () => this.buildMessages(null),
buildMessages: () => this.buildMessages(),
appendAndPersist: (m) => this.appendAndPersist(m),
recordStats: (model, usage) => this.stats.record(this._turn, model, usage),
turn: this._turn,
+1
View File
@@ -25,6 +25,7 @@ export function formatLoopError(err: Error, probe?: DeepSeekProbeResult): string
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 (status === "429") return t("errors.concurrency429", { inner });
if (is5xxStatus(status)) return formatDeepSeek5xx(status, probe);
return msg;
}
+1 -1
View File
@@ -14,7 +14,7 @@ export type EventRole =
| "warning"
/** Transient indicator for silent phases; UI clears on next primary event. */
| "status"
/** Mid-turn steer injected as a user utterance without aborting the current turn. */
/** Mid-turn steer injected as queued user guidance without aborting the current turn. */
| "steer";
export interface LoopEvent {
+41 -12
View File
@@ -17,17 +17,18 @@ const PROXY_ENV_KEYS = [
const NO_PROXY_ENV_KEYS = ["NO_PROXY", "no_proxy"] as const;
// Loopback bypass protects the dashboard, MCP stdio sidecars' HTTP probes, and
// `reasonix doctor` reachability checks; non-negotiable.
const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"] as const;
// DeepSeek's API origin is in CN; routing it through a user's clash/v2ray
// (typically a US-exit pool) lands on shared abuse IPs that DeepSeek 403s.
// Localhost entries protect the dashboard, MCP stdio sidecars' HTTP probes,
// and the `reasonix doctor` reachability checks.
export const DEFAULT_NO_PROXY = [
"api.deepseek.com",
"*.deepseek.com",
"localhost",
"127.0.0.1",
"::1",
] as const;
// Opt-out via `proxy.bypassDeepSeekDirect: false` (config) or
// `REASONIX_PROXY_DEEPSEEK_DIRECT=0` (env) for corporate firewalls that block
// direct egress and require the proxy to reach api.deepseek.com (issue #1497).
const DEEPSEEK_NO_PROXY = ["api.deepseek.com", "*.deepseek.com"] as const;
export const DEFAULT_NO_PROXY = [...DEEPSEEK_NO_PROXY, ...LOOPBACK_NO_PROXY] as const;
export function detectProxyUrl(env: NodeJS.ProcessEnv = process.env): string | null {
for (const key of PROXY_ENV_KEYS) {
@@ -172,6 +173,8 @@ export interface InstallProxyOptions {
disabled?: boolean;
/** Additional NO_PROXY patterns layered on top of defaults + env. Sourced from `cfg.proxy.noProxy` / `REASONIX_NO_PROXY`. */
extraNoProxy?: readonly string[];
/** When false, route api.deepseek.com / *.deepseek.com through the proxy too (issue #1497). Default true preserves the clash/v2ray US-exit-IP 403 fix. */
bypassDeepSeekDirect?: boolean;
}
export interface ResolvedNoProxy {
@@ -183,12 +186,35 @@ export interface ResolvedNoProxy {
all: NoProxyPattern[];
}
/** Env `REASONIX_PROXY_DEEPSEEK_DIRECT` (1/0/true/false/yes/no/on/off) overrides config when set; defaults true. Per issue #1497, corporate firewalls need the env knob since they often can't edit `~/.reasonix/config.json` ergonomically. */
export function resolveBypassDeepSeekDirect(
env: NodeJS.ProcessEnv,
configValue: boolean | undefined,
): boolean {
const raw = env.REASONIX_PROXY_DEEPSEEK_DIRECT;
if (typeof raw === "string") {
const trimmed = raw.trim().toLowerCase();
if (trimmed === "0" || trimmed === "false" || trimmed === "no" || trimmed === "off") {
return false;
}
if (trimmed === "1" || trimmed === "true" || trimmed === "yes" || trimmed === "on") {
return true;
}
}
if (configValue === false) return false;
return true;
}
/** Merge default + env + REASONIX_NO_PROXY + opts.extraNoProxy into one resolved view. Same composition as installProxyIfConfigured so /doctor can show what's actually applied. */
export function resolveNoProxy(
env: NodeJS.ProcessEnv = process.env,
opts: { extraNoProxy?: readonly string[] } = {},
opts: { extraNoProxy?: readonly string[]; bypassDeepSeekDirect?: boolean } = {},
): ResolvedNoProxy {
const defaults = parseNoProxy(DEFAULT_NO_PROXY.join(","));
const wantsDeepSeekDirect = resolveBypassDeepSeekDirect(env, opts.bypassDeepSeekDirect);
const defaultHosts = wantsDeepSeekDirect
? [...DEEPSEEK_NO_PROXY, ...LOOPBACK_NO_PROXY]
: LOOPBACK_NO_PROXY;
const defaults = parseNoProxy(defaultHosts.join(","));
const envSystem = parseNoProxy(detectNoProxyRaw(env));
const envReasonix = parseNoProxy(
typeof env.REASONIX_NO_PROXY === "string" ? env.REASONIX_NO_PROXY : null,
@@ -222,7 +248,10 @@ export function installProxyIfConfigured(
// Default whitelist always applies; env NO_PROXY, REASONIX_NO_PROXY, and
// opts.extraNoProxy (config) all layer on top additively. Composition lives
// in resolveNoProxy() so /doctor and install can't drift.
const { all: patterns } = resolveNoProxy(env, { extraNoProxy: opts.extraNoProxy });
const { all: patterns } = resolveNoProxy(env, {
extraNoProxy: opts.extraNoProxy,
bypassDeepSeekDirect: opts.bypassDeepSeekDirect,
});
try {
const reinstalled = installed;
-4
View File
@@ -870,10 +870,6 @@ export function useQQChannel({
beginThemePicker(buildThemeChoices());
return true;
}
if (result.openCopyMode) {
notifyTerminalOnly("`/copy` follow-up interaction is currently terminal-only.");
return true;
}
if (result.openArgPickerFor) {
notifyTerminalOnly(
`\`/${result.openArgPickerFor}\` needs terminal-side argument completion.`,
+13
View File
@@ -79,6 +79,19 @@ export async function handleModal(
ctx.resolveShellConfirm(choice);
return { status: 200, body: { resolved: true } };
}
if (kind === "path") {
if (!ctx.resolvePathConfirm) {
return { status: 503, body: { error: "path modal resolution not wired" } };
}
if (choice !== "run_once" && choice !== "always_allow" && choice !== "deny") {
return {
status: 400,
body: { error: "path choice must be run_once / always_allow / deny" },
};
}
ctx.resolvePathConfirm(choice);
return { status: 200, body: { resolved: true } };
}
if (kind === "choice") {
if (!ctx.resolveChoiceConfirm) {
return { status: 503, body: { error: "choice modal resolution not wired" } };
+55
View File
@@ -1,10 +1,12 @@
/** apiKey is write-only on the wire; GET always returns a redacted form so dashboard screenshots don't leak credentials. */
import { appendFileSync } from "node:fs";
import {
isPlausibleKey,
normalizeSkillPathEntries,
normalizeSkillPaths,
readConfig,
webSearchEngine as readWebSearchEngine,
redactKey,
saveEditMode,
writeConfig,
@@ -21,6 +23,7 @@ interface SettingsBody {
preset?: unknown;
reasoningEffort?: unknown;
search?: unknown;
webSearchEngine?: unknown;
model?: unknown;
proNext?: unknown;
budgetUsd?: unknown;
@@ -42,6 +45,34 @@ function parseBody(raw: string): SettingsBody {
// read time. Web sends new names in 0.12.x onward.
const VALID_PRESETS = new Set(["auto", "flash", "pro", "fast", "smart", "max"]);
const VALID_EFFORTS = new Set(["high", "max"]);
// Legacy "mojeek" is intentionally absent — the engine was retired (PR
// adding bing default). Old config values map to bing at read; the
// dashboard can't write the dead name back.
const VALID_WEB_SEARCH_ENGINES = new Set([
"bing",
"searxng",
"metaso",
"tavily",
"perplexity",
"exa",
]);
/** Twin of `config.savePreset`'s debug hook — the dashboard PATCH writes `cfg.preset` directly (no `savePreset()` round-trip), so instrument the bypass path too. Same env gate, same file. */
function debugLogPresetWriteFromDashboard(preset: string): void {
const debugPath = process.env.REASONIX_DEBUG_PRESET;
if (!debugPath) return;
try {
const stack = new Error("trace").stack ?? "";
const line = `${new Date().toISOString()} dashboard PATCH /api/settings { preset: ${JSON.stringify(preset)} }\n${stack
.split("\n")
.slice(1, 8)
.map((l) => ` ${l.trim()}`)
.join("\n")}\n\n`;
appendFileSync(debugPath, line);
} catch {
/* diagnostic only */
}
}
export async function handleSettings(
method: string,
@@ -66,6 +97,7 @@ export async function handleSettings(
preset: cfg.preset ?? "auto",
reasoningEffort: cfg.reasoningEffort ?? "max",
search: cfg.search !== false,
webSearchEngine: readWebSearchEngine(ctx.configPath),
editMode: cfg.editMode ?? "review",
session: cfg.session ?? null,
model: live?.model ?? null,
@@ -87,6 +119,7 @@ export async function handleSettings(
preset: "next-session",
reasoningEffort: "next-turn",
search: "next-session",
webSearchEngine: "next-turn",
model: "next-turn",
proNext: "next-turn",
budgetUsd: "live",
@@ -139,6 +172,7 @@ export async function handleSettings(
if (typeof fields.preset !== "string" || !VALID_PRESETS.has(fields.preset)) {
return { status: 400, body: { error: "preset must be auto | flash | pro" } };
}
debugLogPresetWriteFromDashboard(fields.preset);
cfg.preset = fields.preset as "auto" | "flash" | "pro" | "fast" | "smart" | "max";
presetPendingLive = fields.preset;
changed.push("preset");
@@ -161,6 +195,27 @@ export async function handleSettings(
cfg.search = fields.search;
changed.push("search");
}
if (fields.webSearchEngine !== undefined) {
if (
typeof fields.webSearchEngine !== "string" ||
!VALID_WEB_SEARCH_ENGINES.has(fields.webSearchEngine)
) {
return {
status: 400,
body: {
error: "webSearchEngine must be bing | searxng | metaso | tavily | perplexity | exa",
},
};
}
cfg.webSearchEngine = fields.webSearchEngine as
| "bing"
| "searxng"
| "metaso"
| "tavily"
| "perplexity"
| "exa";
changed.push("webSearchEngine");
}
let modelPendingLive: string | null = null;
let proNextPending: boolean | null = null;
let budgetPending: number | null | undefined;
+9
View File
@@ -73,6 +73,7 @@ export interface DashboardContext {
/** Snapshot of any modal currently up (for SSE clients that connect mid-modal). */
getActiveModal?: () => ActiveModal | null;
resolveShellConfirm?: (choice: "run_once" | "always_allow" | "deny") => void;
resolvePathConfirm?: (choice: "run_once" | "always_allow" | "deny") => void;
resolveChoiceConfirm?: (choice: ChoiceResolution) => void;
resolvePlanConfirm?: (choice: "approve" | "refine" | "cancel", text?: string) => void;
resolveEditReview?: (choice: "apply" | "reject" | "apply-rest-of-turn" | "flip-to-auto") => void;
@@ -159,6 +160,14 @@ export type ActiveModal =
allowPrefix: string;
shellKind: "run_command" | "run_background";
}
| {
kind: "path";
path: string;
intent: "read" | "write";
toolName: string;
sandboxRoot: string;
allowPrefix: string;
}
| {
kind: "choice";
question: string;
+29
View File
@@ -1,12 +1,20 @@
import type { PauseGate } from "./core/pause-gate.js";
import { truncateForModel, truncateForModelByTokens } from "./mcp/registry.js";
import { analyzeSchema, flattenSchema, nestArguments } from "./repair/flatten.js";
import {
type NormalizedToolRateLimitConfig,
type ToolRateLimitOption,
ToolRateLimiter,
} from "./tools/rate-limit.js";
import type { ReadTracker } from "./tools/read-tracker.js";
import type { JSONSchema, ToolSpec } from "./types.js";
export interface ToolCallContext {
signal?: AbortSignal;
/** Inject a mock PauseGate for tests. When absent, tools use the singleton. */
confirmationGate?: PauseGate;
/** Per-session tracker of files the model has read. Filesystem tools mark on read/write, edit_file/multi_edit consult before mutating. */
readTracker?: ReadTracker;
}
export interface ToolDefinition<A = any, R = any> {
@@ -32,6 +40,7 @@ interface InternalTool extends ToolDefinition {
export interface ToolRegistryOptions {
/** Auto-flatten + re-nest at dispatch; default true. */
autoFlatten?: boolean;
rateLimit?: ToolRateLimitOption;
}
export type ToolCallAuditEvent = {
@@ -62,6 +71,7 @@ export class ToolRegistry {
private readonly _interceptors: Array<{ id: string; fn: ToolInterceptor }> = [];
private _auditListener: ToolCallAuditListener | null = null;
private _resultAugmenter: ToolResultAugmenter | null = null;
private readonly _rateLimiter: ToolRateLimiter;
/** Per-tool fingerprint of the last call that failed schema validation. Cleared by any successful validation for that tool. */
private readonly _lastMalformed = new Map<string, string>();
/** Per-tool fingerprint of the last host-side gate rejection. */
@@ -69,6 +79,7 @@ export class ToolRegistry {
constructor(opts: ToolRegistryOptions = {}) {
this._autoFlatten = opts.autoFlatten !== false;
this._rateLimiter = new ToolRateLimiter(opts.rateLimit);
}
/** Enable / disable plan-mode enforcement at dispatch. */
@@ -113,6 +124,10 @@ export class ToolRegistry {
return this._resultAugmenter !== null;
}
get rateLimitPolicy(): false | NormalizedToolRateLimitConfig {
return this._rateLimiter.policy;
}
register<A, R>(def: ToolDefinition<A, R>): this {
if (!def.name) throw new Error("tool requires a name");
const internal: InternalTool = { ...(def as ToolDefinition) };
@@ -173,6 +188,8 @@ export class ToolRegistry {
maxResultTokens?: number;
/** Inject a mock PauseGate for tests. */
confirmationGate?: PauseGate;
/** Session-scoped read tracker; filesystem tools mark on read/write, edit_file/multi_edit gate on it. */
readTracker?: ReadTracker;
} = {},
): Promise<string> {
const tool = this._tools.get(name);
@@ -260,6 +277,12 @@ export class ToolRegistry {
});
}
// Only real dispatch attempts consume quota; earlier refusals are guidance, not work.
const rateLimit = this._rateLimiter.consume(name);
if (!rateLimit.allowed) {
return JSON.stringify(rateLimit.result);
}
let finalResult: string;
try {
try {
@@ -270,6 +293,7 @@ export class ToolRegistry {
const result = await tool.fn(args, {
signal: opts.signal,
confirmationGate: opts.confirmationGate,
readTracker: opts.readTracker,
});
const str = typeof result === "string" ? result : JSON.stringify(result);
// Pre-clip at dispatch so a single fat result can't balloon the
@@ -375,6 +399,9 @@ function plainTextRejectedReason(name: string, result: string): string | null {
if ((name === "edit_file" || name === "write_file") && /rejected this edit/i.test(result)) {
return "edit-gate";
}
if ((name === "edit_file" || name === "multi_edit") && /read_file first/i.test(result)) {
return "read-before-edit";
}
if ((name === "run_command" || name === "run_background") && /\buser denied:/i.test(result)) {
return "shell-gate";
}
@@ -385,6 +412,8 @@ function rejectionRecoveryHint(reason: string): string {
switch (reason) {
case "edit-gate":
return "Do not re-emit the same edit. Try a genuinely different edit or ask the user how to proceed.";
case "read-before-edit":
return "Call read_file on the target path first, then re-issue the edit.";
case "shell-gate":
return "Do not retry the same command. Use an allowlisted/read-only command, wait for approval, or ask the user how to proceed.";
case "engineering-lifecycle":
+29 -6
View File
@@ -3,6 +3,7 @@
import { promises as fs } from "node:fs";
import * as pathMod from "node:path";
import picomatch from "picomatch";
import { decodeFileBuffer, encodeFile } from "../code/file-encoding.js";
import { addProjectPathAllowed, loadProjectPathAllowed } from "../config.js";
import { type ConfirmationChoice, pauseGate as defaultPauseGate } from "../core/pause-gate.js";
import { DEFAULT_INDEX_EXCLUDES } from "../index/config.js";
@@ -280,7 +281,11 @@ export function registerFilesystemTools(
return `[refused: ${rel} appears to be binary (${formatBytes(sizeBytes)}) — read_file returns text only. Use get_file_info for stat.]`;
}
const text = raw.toString("utf8");
const { text } = decodeFileBuffer(raw);
// Any successful read (full, range, head, tail, outline) marks the
// file as seen so the edit gate accepts subsequent edits. Partial-
// read mistakes still fail later via "search text not found".
ctx?.readTracker?.markRead(abs);
let lines = text.split(/\r?\n/);
// Most files end with '\n' which splits into an empty trailing
// entry; drop it so head/tail/range counts match the user's
@@ -653,7 +658,16 @@ export function registerFilesystemTools(
fn: async (args: { path: string; content: string }, ctx?: ToolCallContext) => {
const abs = await safePath(args.path, "write_file", ctx, "write");
await fs.mkdir(pathMod.dirname(abs), { recursive: true });
await fs.writeFile(abs, args.content, "utf8");
let encoding: ReturnType<typeof decodeFileBuffer>["encoding"] = "utf8";
try {
encoding = decodeFileBuffer(await fs.readFile(abs)).encoding;
} catch {
// New file or unreadable — fall back to utf8.
}
await fs.writeFile(abs, encodeFile(args.content, encoding));
// Just wrote the content; the model knows what's on disk, so a
// follow-up edit_file shouldn't be gated for re-reading.
ctx?.readTracker?.markRead(abs);
return `wrote ${args.content.length} chars to ${displayRel(rootDir, abs)}`;
},
});
@@ -661,7 +675,7 @@ export function registerFilesystemTools(
registry.register({
name: "edit_file",
description:
"Apply a SEARCH/REPLACE edit to an existing file. `search` must match exactly (whitespace sensitive) — no regex. The match must be unique in the file; otherwise the edit is refused to avoid surprise rewrites.",
"Apply a SEARCH/REPLACE edit to an existing file. Call `read_file` on this path first this session — the tool refuses otherwise, since SEARCH must match on-disk bytes exactly. `search` is whitespace-sensitive plain text (no regex) and must be unique in the file; otherwise the edit is refused to avoid surprise rewrites.",
parameters: {
type: "object",
properties: {
@@ -672,13 +686,18 @@ export function registerFilesystemTools(
required: ["path", "search", "replace"],
},
fn: async (args: { path: string; search: string; replace: string }, ctx?: ToolCallContext) =>
applyEdit(rootDir, await safePath(args.path, "edit_file", ctx, "write"), args),
applyEdit(
rootDir,
await safePath(args.path, "edit_file", ctx, "write"),
args,
ctx?.readTracker ? (abs) => ctx.readTracker!.hasRead(abs) : undefined,
),
});
registry.register({
name: "multi_edit",
description:
"Apply N SEARCH/REPLACE edits across ONE OR MORE files in one call. Edits validate across the full batch before writing. Validation failures leave all files untouched; disk write failures trigger best-effort rollback of files that may have been modified. Per-file edits run in array order, so a later edit can match text inserted by an earlier one. Same per-edit rules as edit_file: `search` is exact text (whitespace sensitive, no regex) and must be unique in its target file at the moment that edit applies. Use this for renames spanning multiple files, cross-file refactors, or any batch where you'd otherwise loop edit_file.",
"Apply N SEARCH/REPLACE edits across ONE OR MORE files in one call. Every target file must have been `read_file`'d this session — the tool refuses the whole batch otherwise. Edits validate across the full batch before writing. Validation failures leave all files untouched; disk write failures trigger best-effort rollback of files that may have been modified. Per-file edits run in array order, so a later edit can match text inserted by an earlier one. Same per-edit rules as edit_file: `search` is exact text (whitespace sensitive, no regex) and must be unique in its target file at the moment that edit applies. Use this for renames spanning multiple files, cross-file refactors, or any batch where you'd otherwise loop edit_file.",
parameters: {
type: "object",
properties: {
@@ -715,7 +734,11 @@ export function registerFilesystemTools(
replace: e?.replace,
})),
);
return applyMultiEdit(rootDir, resolved);
return applyMultiEdit(
rootDir,
resolved,
ctx?.readTracker ? (abs) => ctx.readTracker!.hasRead(abs) : undefined,
);
},
});
+28 -8
View File
@@ -1,19 +1,30 @@
import { promises as fs } from "node:fs";
import * as pathMod from "node:path";
import { type FileEncoding, decodeFileBuffer, encodeFile } from "../../code/file-encoding.js";
function displayRel(rootDir: string, full: string): string {
return pathMod.relative(rootDir, full).replaceAll("\\", "/");
}
/** Marker substring in the gate-reject message so tools.ts's repeat-rejection tracker spots a 2nd identical unread-edit and switches to the sharper "stop retrying" hint. */
export const READ_BEFORE_EDIT_MARKER = "read_file first";
export async function applyEdit(
rootDir: string,
abs: string,
args: { search: string; replace: string },
hasRead?: (abs: string) => boolean,
): Promise<string> {
if (args.search.length === 0) {
throw new Error("edit_file: search cannot be empty");
}
const before = await fs.readFile(abs, "utf8");
if (hasRead && !hasRead(abs)) {
throw new Error(
`edit_file: ${displayRel(rootDir, abs)} was not read this session — ${READ_BEFORE_EDIT_MARKER} so your SEARCH matches the bytes on disk.`,
);
}
const beforeBuf = await fs.readFile(abs);
const { text: before, encoding } = decodeFileBuffer(beforeBuf);
const le = before.includes("\r\n") ? "\r\n" : "\n";
const adaptedSearch = args.search.replace(/\r?\n/g, le);
const adaptedReplace = args.replace.replace(/\r?\n/g, le);
@@ -29,7 +40,7 @@ export async function applyEdit(
}
const after =
before.slice(0, firstIdx) + adaptedReplace + before.slice(firstIdx + adaptedSearch.length);
await fs.writeFile(abs, after, "utf8");
await fs.writeFile(abs, encodeFile(after, encoding));
const rel = displayRel(rootDir, abs);
const header = `edited ${rel} (${adaptedSearch.length}→${adaptedReplace.length} chars)`;
const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
@@ -46,6 +57,7 @@ export interface MultiEditEntry {
export async function applyMultiEdit(
rootDir: string,
edits: ReadonlyArray<MultiEditEntry>,
hasRead?: (abs: string) => boolean,
): Promise<string> {
if (edits.length === 0) {
throw new Error("multi_edit: edits must contain at least one entry");
@@ -57,6 +69,7 @@ export async function applyMultiEdit(
hunks: string[];
deltaChars: number;
touched: number;
encoding: FileEncoding;
};
const filesByPath = new Map<string, FileState>();
@@ -81,16 +94,23 @@ export async function applyMultiEdit(
}
let state = filesByPath.get(e.abs);
if (!state) {
if (hasRead && !hasRead(e.abs)) {
throw new Error(
`multi_edit: edit #${i + 1} target ${rel} was not read this session — ${READ_BEFORE_EDIT_MARKER} (no edits applied)`,
);
}
let before: string;
let encoding: FileEncoding;
try {
before = await fs.readFile(e.abs, "utf8");
const buf = await fs.readFile(e.abs);
({ text: before, encoding } = decodeFileBuffer(buf));
} catch (err) {
throw new Error(
`multi_edit: edit #${i + 1} cannot read ${rel}: ${(err as Error).message} (no edits applied)`,
);
}
const le = before.includes("\r\n") ? "\r\n" : "\n";
state = { before, buf: before, le, hunks: [], deltaChars: 0, touched: 0 };
state = { before, buf: before, le, hunks: [], deltaChars: 0, touched: 0, encoding };
filesByPath.set(e.abs, state);
}
const adaptedSearch = e.search.replace(/\r?\n/g, state.le);
@@ -119,17 +139,17 @@ export async function applyMultiEdit(
// Push to `attempted` BEFORE writeFile so a write that truncates or
// partially-writes before failing is also rolled back.
const attempted: Array<{ abs: string; before: string }> = [];
const attempted: Array<{ abs: string; before: string; encoding: FileEncoding }> = [];
try {
for (const [abs, state] of filesByPath) {
attempted.push({ abs, before: state.before });
await fs.writeFile(abs, state.buf, "utf8");
attempted.push({ abs, before: state.before, encoding: state.encoding });
await fs.writeFile(abs, encodeFile(state.buf, state.encoding));
}
} catch (writeErr) {
const rollbackFailures: string[] = [];
for (const item of [...attempted].reverse()) {
try {
await fs.writeFile(item.abs, item.before, "utf8");
await fs.writeFile(item.abs, encodeFile(item.before, item.encoding));
} catch (restoreErr) {
rollbackFailures.push(`${displayRel(rootDir, item.abs)}: ${(restoreErr as Error).message}`);
}
+177
View File
@@ -0,0 +1,177 @@
/** Sliding-window limits for per-session tool dispatch, with aggregate and per-tool buckets. */
export interface ToolRateLimitBucketConfig {
maxCalls?: number;
windowSeconds?: number;
}
export interface ToolRateLimitConfig {
enabled?: boolean;
aggregate?: ToolRateLimitBucketConfig;
tools?: Record<string, false | ToolRateLimitBucketConfig>;
}
export interface NormalizedToolRateLimitBucket {
maxCalls: number;
windowSeconds: number;
}
export interface NormalizedToolRateLimitConfig {
aggregate: NormalizedToolRateLimitBucket;
tools: Record<string, false | NormalizedToolRateLimitBucket>;
}
export interface RateLimitedToolResult {
error: "rate_limited";
tool: string;
scope: string;
limit: number;
windowSeconds: number;
retryAfterMs: number;
message: string;
}
export type ToolRateLimitDecision =
| { allowed: true }
| { allowed: false; result: RateLimitedToolResult };
// Generous defaults make this a host-pressure guard, not a normal workload budget.
export const DEFAULT_TOOL_RATE_LIMIT: NormalizedToolRateLimitConfig = {
aggregate: { maxCalls: 200, windowSeconds: 60 },
tools: {
run_command: { maxCalls: 60, windowSeconds: 60 },
run_background: { maxCalls: 10, windowSeconds: 60 },
},
};
export type ToolRateLimitOption = false | ToolRateLimitConfig;
type Clock = () => number;
export class ToolRateLimiter {
private readonly config: false | NormalizedToolRateLimitConfig;
private readonly clock: Clock;
private readonly aggregate: number[] = [];
private readonly tools = new Map<string, number[]>();
constructor(config: ToolRateLimitOption | undefined = {}, clock: Clock = () => Date.now()) {
this.config = normalizeToolRateLimitConfig(config);
this.clock = clock;
}
get policy(): false | NormalizedToolRateLimitConfig {
return this.config;
}
consume(tool: string): ToolRateLimitDecision {
if (this.config === false) return { allowed: true };
const now = this.clock();
const toolBucket = this.config.tools[tool];
if (toolBucket !== false && toolBucket !== undefined) {
const timestamps = this.timestampsFor(tool);
const blocked = inspectBucket(tool, timestamps, toolBucket, now);
if (blocked) return { allowed: false, result: blocked };
}
const aggregateBlocked = inspectBucket(
tool,
this.aggregate,
this.config.aggregate,
now,
"all_tools",
);
if (aggregateBlocked) return { allowed: false, result: aggregateBlocked };
// Count at dispatch start so concurrent slow tools still occupy the active window.
this.aggregate.push(now);
if (toolBucket !== false && toolBucket !== undefined) this.timestampsFor(tool).push(now);
return { allowed: true };
}
private timestampsFor(tool: string): number[] {
const existing = this.tools.get(tool);
if (existing) return existing;
const created: number[] = [];
this.tools.set(tool, created);
return created;
}
}
export function normalizeToolRateLimitConfig(
config: ToolRateLimitOption | undefined,
): false | NormalizedToolRateLimitConfig {
if (config === false || config?.enabled === false) return false;
const aggregate = normalizeBucket(config?.aggregate, DEFAULT_TOOL_RATE_LIMIT.aggregate);
const tools: Record<string, false | NormalizedToolRateLimitBucket> = {
...DEFAULT_TOOL_RATE_LIMIT.tools,
};
for (const [name, value] of Object.entries(config?.tools ?? {})) {
if (value === false) {
tools[name] = false;
continue;
}
const fallback = DEFAULT_TOOL_RATE_LIMIT.tools[name];
tools[name] = normalizeBucket(
value,
fallback === false || fallback === undefined ? DEFAULT_TOOL_RATE_LIMIT.aggregate : fallback,
);
}
return { aggregate, tools };
}
export function parseRateLimitedToolResult(result: string): RateLimitedToolResult | null {
try {
const parsed = JSON.parse(result) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const value = parsed as Partial<RateLimitedToolResult>;
if (value.error !== "rate_limited") return null;
if (typeof value.tool !== "string" || typeof value.scope !== "string") return null;
if (typeof value.limit !== "number" || typeof value.windowSeconds !== "number") return null;
if (typeof value.retryAfterMs !== "number" || typeof value.message !== "string") return null;
return value as RateLimitedToolResult;
} catch {
return null;
}
}
function normalizeBucket(
raw: ToolRateLimitBucketConfig | undefined,
fallback: NormalizedToolRateLimitBucket,
): NormalizedToolRateLimitBucket {
return {
maxCalls: positiveInteger(raw?.maxCalls) ?? fallback.maxCalls,
windowSeconds: positiveInteger(raw?.windowSeconds) ?? fallback.windowSeconds,
};
}
function positiveInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function inspectBucket(
tool: string,
timestamps: number[],
bucket: NormalizedToolRateLimitBucket,
now: number,
scope = tool,
): RateLimitedToolResult | null {
const windowMs = bucket.windowSeconds * 1_000;
while (timestamps.length > 0 && now - timestamps[0]! >= windowMs) timestamps.shift();
if (timestamps.length < bucket.maxCalls) return null;
const retryAfterMs = Math.max(0, timestamps[0]! + windowMs - now);
return {
error: "rate_limited",
tool,
scope,
limit: bucket.maxCalls,
windowSeconds: bucket.windowSeconds,
retryAfterMs,
message: `${scope} rate-limited: ${bucket.maxCalls} calls / ${bucket.windowSeconds}s. Wait ${formatWait(retryAfterMs)} or summarize what you know.`,
};
}
function formatWait(ms: number): string {
const seconds = ms / 1_000;
return `${Number.isInteger(seconds) ? seconds.toFixed(0) : seconds.toFixed(1)}s`;
}
+27
View File
@@ -0,0 +1,27 @@
import * as pathMod from "node:path";
/** Tracks files the model has had byte-exact visibility into this session. `edit_file` and `multi_edit` consult it before mutating, so the SEARCH text matches the on-disk bytes the model actually saw — not what it guessed. */
export class ReadTracker {
private readonly _seen = new Set<string>();
private static norm(abs: string): string {
const resolved = pathMod.resolve(abs);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
markRead(abs: string): void {
this._seen.add(ReadTracker.norm(abs));
}
hasRead(abs: string): boolean {
return this._seen.has(ReadTracker.norm(abs));
}
reset(): void {
this._seen.clear();
}
get size(): number {
return this._seen.size;
}
}
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more