Compare commits

...
29 Commits
Author SHA1 Message Date
YHH f9a1d0912d chore(release): 0.31.0 (#450) 2026-05-08 20:23:00 +08:00
YHH 3f3b5e2fe7 i18n(slash): localize 4 lagging slash handlers (#448)
Most handler files were already 80%+ i18n'd from prior work, but
four files had drift:

  - web-search-engine.ts — 0 t() calls, the entire help text plus
    switch confirmation was hardcoded English. Whole new
    handlers.webSearchEngine namespace (~12 keys).
  - mcp.ts — 5 hardcoded usage / unknown-server / "(none)" /
    reconnect-not-wired strings; added under handlers.mcp.
  - plans.ts — the archive row template ("✓ {when} {N} steps · …"),
    "complete" verb, and the /stop abort line. Added under
    handlers.plans.
  - semantic.ts — code-mode-only refusal + "checking…" status.
    Added under handlers.semantic. Note this file uses two t()
    instances side-by-side — the local @/index/semantic/i18n one
    stays for the existing slashHeader/slashEnabled keys, and the
    main @/i18n/index t (aliased as tMain) is added for the two new
    strings.

zh-CN translations included for all ~22 new keys.

Test plan: full suite 2301 pass, tsc + biome clean.
2026-05-08 20:14:24 +08:00
YHH 0990491584 i18n(app): localize ~26 user-facing strings in App.tsx (#447)
Two patterns rolled into one PR:

1. Wire seven existing ui.* keys that had been defined in EN.ts /
   zh-CN.ts but never actually called: ephemeralSession,
   resumedSession, newSession, restoredEdits, resumedPlan,
   tipEditBindings, dashboardAutoStartFailed. The keys had drifted
   into dead code — same pattern that bit the loop.* namespace
   before #445.

2. New app.* namespace (~24 keys) covering every other hardcoded
   user-facing string in App.tsx:
   - walk cancel (with / without remaining count)
   - edit-mode cycle (review / auto / yolo)
   - reject / auto-approve / flip-to-auto messages
   - dashboard stopped, /restore code-mode-only
   - hash-memory note (with translated scope + verb)
   - bash-mode + memory-write failures
   - hook headers (UserPromptSubmit, Stop)
   - @mentions / @url expansion + failure
   - shell confirm (deny / always-allow / running / starting)
   - checkpoint saved
   - plan continue / stop / revise

zh-CN translations for all 31 new keys.

Test plan: full suite 2301 pass, tsc + biome clean.
2026-05-08 20:01:27 +08:00
YHH 40628d0025 i18n(hooks/summary): localize hook outcome and force-summary text (#446)
Two small follow-on batches after #445:

1. hooks.formatHookOutcomeMessage — was hardcoded
   \`hook \${tag} \\`\${cmd}\\` \${decision}\${truncTag}\`. Pulls
   the structural words (\"hook\", decision verb, \" (output truncated
   at 256KB)\") into a new \`hooks.*\` namespace. The technical bits
   (tag, cmd) stay verbatim — they're identifiers, not prose.

2. loop/force-summary.ts — three user-visible strings:
   - \"summarizing what was gathered…\" (status during summary call)
   - the fallback when the model emits hallucinated tool-call markup
     instead of prose
   - the error template when the fallback summary itself crashes

New \`summary.*\` namespace covers all three. zh-CN translations included.

Decision verbs (block / warn / timeout / error) are translated via a
small \`hooks.decision\${Capitalize}\` lookup so the formatter still
takes \`outcome.decision\` (the typed enum) as input.

Test plan: full suite 2301 pass; tsc + biome clean.
2026-05-08 19:50:23 +08:00
YHH 346bb35cb8 i18n(loop): localize 14 user-facing yields in step() (#445)
Yields like 'session budget exhausted', 'aborted at iter X/Y',
'preflight: request ~X/Y tokens', and the post-turn fold/forcing-
summary status lines were all hardcoded English. The loop.* i18n
namespace had 7 keys defined but never wired — placeholders from a
previous attempt that drifted away from the actual call sites.

This PR replaces the dead namespace with 20 keys that match the
actual yield shapes, wires t() at every site, and adds zh-CN
translations.

Categories covered:
  - budget (exhausted / 80% warning)
  - /pro armed
  - aborted-at-iter / tool-budget-warning
  - preflight (status + folded / no-fold-left)
  - escalation (flash → pro / auto-escalation)
  - storm-broken (stuck retry loop / suppressed-N-times)
  - history compaction (status + folded / aggressively-folded)
  - forcing summary (context-guard exit)

Behavior preserved: tests pin runtime to EN, so existing assertions
on the English text still pass. The dead loop.* keys (budgetWarning,
budgetRefusal, contextWarning, escalationWarning, toolFailure,
interrupted, loopStopped) had zero callers across src/ and tests/
and are removed as part of the namespace cleanup.
2026-05-08 19:38:39 +08:00
YHH 97a93cf4f5 i18n(loop/errors): localize DeepSeek error messages (#444)
formatLoopError, reasonPrefixFor, and errorLabelFor were all
hardcoded English. A Chinese user hitting a 503 / 401 / context
overflow saw raw English (and on top of that, the new 5xx outage
notice from #440 was also English).

Move the strings to a new errors.* i18n namespace covering:
  - context overflow (with V4/legacy limit mention)
  - 401 auth / 402 balance / 422 param / 400 bad request
  - 5xx head + reachable / unreachable / two action variants
  - reason prefix and label for budget/aborted/context-guard/stuck
  - "(no message)" fallback for empty error bodies

zh-CN translations included. Existing tests still cover EN (vitest
setupFile pins runtime to EN); two new tests flip to zh-CN to confirm
runtime switch actually translates.

Stacked on #440 — merge that first.
2026-05-08 19:28:18 +08:00
YHH d837ddaf43 fix(loop): friendly DeepSeek 5xx error with reachability probe (#440)
* fix(loop): friendly DeepSeek 5xx error with reachability probe

Raw `DeepSeek 503: <body>` was bubbling straight to the UI. Users on
flaky DS days couldn't tell if it was DS-side (their service was
overloaded) or Reasonix-side (we crashed). The header comment in
loop/errors.ts even claimed retry.ts swallowed all 5xx — but retry.ts
returns the last response instead of swallowing, so 5xx did reach the
formatter and just fell through to a passthrough branch.

Now formatLoopError takes an optional probe result. The catch in
step() detects 5xx, fires a 1.5s `/user/balance` probe, and passes
the result back. Three message variants:
  - no probe         → DS-side outage notice + retry hints
  - probe reachable  → "main API answered, /chat/completions failing"
  - probe down       → "DS unreachable from your network — check net"

All three include "this is a DeepSeek-side problem, not Reasonix" and
a status-page link so users know where to look.

* test: avoid CodeQL js/regex/missing-regexp-anchor on URL substring match

Use toContain('status.deepseek.com') instead of /status\.deepseek\.com/
— same assertion semantics, dodges CodeQL's high-severity false positive
on unanchored URL regex.
2026-05-08 19:22:58 +08:00
YHH c56cd9d9a0 i18n(dashboard): translate the plan 'idle' status pill (#443)
Plans whose completionRatio is 0 (submitted but no steps marked
complete yet) showed a hardcoded English "idle" pill — surrounding
"active" / "done" pills already used t().

Add plans.idle to en.ts ("idle") and zh-CN.ts ("未开始"), and route
the idle branch of statusPill() through t().
2026-05-08 19:22:21 +08:00
YHH db194cb833 feat(wizard): first-launch language picker + i18n the wizard (#442)
Two related fixes for non-English first-runs:

1. Add a language step at the very start of the setup wizard.
   Cursor defaults to detectSystemLanguage() result. Selection saves
   immediately, so all later wizard steps render in the chosen
   language. Re-runs (`reasonix setup`) start there too — cursor
   defaults to currently saved language so Enter is a no-op for
   users who don't want to change it.

2. Localize every hardcoded string in the wizard via a new wizard.*
   i18n namespace (~30 keys, EN + zh-CN). Previously a Chinese user
   who hit /language partway through never saw zh-CN copy because
   the wizard rendered raw English. Now language → applies → review
   step renders in zh-CN.

Why this matters: Intl.DateTimeFormat().resolvedOptions().locale
isn't always correct (terminal env strips LANG, CI returns "C", etc.),
and the prior workflow only let users discover /language *after*
finishing setup in English.

Tests: ink-testing-library renders the Wizard and confirms the
language step is shown first with both options visible, in EN and
zh-CN. wizard.test.ts → wizard.test.tsx (now contains JSX).
2026-05-08 19:22:17 +08:00
YHH d817f1b965 docs(dashboard): PARITY.md audit — bucket E of #369 (#439)
Closing audit of the #369 split: every slash command tagged with how
the dashboard handles it (panel / chat-box / tui-only).

Findings after C + D:

* ~20 slashes have a dedicated panel or modal — Settings (presets,
  effort, model, /pro, budget gauge, loop control, language), MCP,
  Permissions, Hooks, Skills, Memory, Plans, Sessions, Usage, plus
  the C-bucket pickers (SessionPicker, CheckpointPicker, MCP
  marketplace, plan replay viewer).
* ~38 slashes work as-is via the chat box — short text outputs
  (/help, /keys, /status, /cost, /context, /think, /retry, /compact,
  /update, /doctor, /init, /semantic, …) plus admin / code-mode
  niches (/walk-around, /resource, /prompt, …). No structured-output
  gaps remain after C + D — the long-standing Memory / Permissions /
  Hooks / Skills / Plans / Usage panels already cover every command
  whose payload warranted a dedicated UI.
* Stays TUI-only by design: /exit (no live process to kill from web),
  the raw keyboard shortcuts (Esc / Shift+Tab / Ctrl-L / Ctrl-O / u
  / space).

Bucket A and B turned out empty after the C/D work — no commands
need a chat-box rendering tweak, and no commands need a new
structured-output panel. PARITY.md is the source of truth going
forward; add a row when a new slash lands.

This wraps up #369 — the umbrella issue closes with this PR.
2026-05-08 18:39:40 +08:00
YHH 9ea76a3ef9 feat(dashboard): loop control panel — start / stop / countdown (#433) (#438)
Final stage of #428. Adds a Loop section to SettingsPanel for the
auto-resubmit timer.

Design — two states from one component:

* Idle → 6 quick interval buttons (30s / 1m / 5m / 15m / 1h / 6h) plus
  a custom field with s/m/h unit selector, a multi-line prompt
  textarea, and a Start button. Cost hint reads "each iteration costs
  ~$X.XXXX (last turn)" pulled from /api/overview stats so the user
  knows what an unattended overnight loop will run.
* Running → live countdown ("fires in 1m 24s"), iter counter, the
  prompt verbatim (max 120px scroll for long prompts), and a Stop
  button.

Implementation:

* dashboard/src/lib/loop-control.ts — pure helpers: INTERVAL_PRESETS_MS
  table, parseCustomInterval (s/m/h → ms with 5s..6h clamp),
  formatRemaining (5m 12s / 2h 45m / 0s for races past zero).
* SettingsPanel polls /api/loop/status every 5s; a separate 1s tick
  interpolates the countdown locally between syncs (clamps to 0 if
  the next-fire moment passes between polls).
* Page-reload mid-loop reads /api/loop/status and re-renders the
  running panel from server state — no double-start risk because the
  Start button only renders in idle state.

Wraps up bucket D — all four targeted slashes (/pro, /budget, /model,
/loop) are now panel-driven.
2026-05-08 18:00:17 +08:00
YHH 136ed9ff58 feat(dashboard): editable model picker in settings (#432) (#437)
Fourth stage of #428. Replaces the read-only active-model code line
with a select driven by /api/models, plus an inline pricing line.

* New /api/models GET — returns the cached model catalog
  (string[] | null), the live current model, and the DEEPSEEK_PRICING
  table so the SPA can render input/output USD per 1M tok without a
  second source.
* DashboardContext.getModels callback; App.tsx ref-mirrors useSessionInfo's
  models so the dashboard server (frozen at boot time) sees fresh
  catalog without re-mint.
* SettingsPanel ModelRow component: graceful fallback to the existing
  read-only display when the catalog is null/empty (network failure
  or in-flight). Live model is always selectable, even if the catalog
  hasn't echoed it yet (preset overrides, custom IDs).
* Save flows through the existing D-1 applyModelLive path — same as
  /model <id> slash.

Closes #432
2026-05-08 17:56:18 +08:00
YHH 5dbcd1b73b feat(dashboard): budget gauge + cockpit tile (#431) (#436)
Third stage of #428. Replaces a typed-input form with a state-driven
budget gauge that reads at a glance and turns the common bumps into
single clicks.

Design:

* Off → quick-cap row ($1 / $5 / $10 / $25 / $50) + custom field. No
  number to type for the common case.
* Running (<80%) → spend / cap progress bar + bump suggestions
  (1.5× / 2× / 4× current cap, snapped to a "nice" round number per
  size bucket) + Clear.
* Approaching (≥80%) → amber tone + "loop will refuse past 100%" line
  + bump suggestions promoted to primary buttons.
* Exhausted (≥100%) → red tone + "next turn refused" line + same
  bumps.

Implementation:

* dashboard/src/lib/budget.ts — pure helpers: BudgetState union,
  deriveBudgetState, bumpSuggestions, budgetTone, QUICK_CAPS_USD.
  niceUp() subtracts an FP epsilon before ceil so 0.4 × 1.5 doesn't
  bump 0.6 to 0.7.
* SettingsPanel: BudgetSection consumes derived state and renders
  off / running / warn / exhausted variants. Same gauge shape across
  all three live states (just colour + helper text differ).
* Overview cockpit: budgetKpi reuses deriveBudgetState + budgetTone
  so threshold logic lives in one place.
* /api/settings GET adds sessionSpendUsd from ctx.getStats so the
  panel renders without a second /overview round-trip.
* 12 new unit tests for the derivation and bump rounding.
2026-05-08 17:52:12 +08:00
YHH 447d1d7049 feat(dashboard): compute panel section — /pro one-shot (#430) (#435)
* feat(dashboard): server surface for /pro / /budget / /model / /loop (#429)

First stage of #428 (bucket-D of #369). Server-side plumbing for the
slash-only knobs the web should drive directly: /pro arming, /budget,
/model, and /loop. No SPA UI yet — D-2..D-5 each ship a panel section
against this surface.

* /api/settings GET exposes proNext / budgetUsd / model from live
  loop state alongside the existing persisted fields.
* /api/settings POST validates and routes each new field to the
  matching ctx.*Live callback. budgetUsd accepts null for "off".
* /api/loop/{status,start,stop} (new endpoint) drives the auto-resubmit
  timer the same way the /loop slash does. start enforces 5s..6h range
  and a non-empty prompt.
* DashboardContext gets matching callbacks: applyModelLive,
  setProNextLive, setBudgetUsdLive, getLoopRunStatus, startAutoLoop,
  stopAutoLoop.
* App.tsx wires each callback against existing live methods on
  CacheFirstLoop and the useLoopMode hook. No new state.

/harvest and /branch are deletion-bound features and are deliberately
out of scope here — see #428 follow-up.

Closes #429

* feat(dashboard): compute panel section — /pro one-shot (#430)

Second stage of #428. Adds a Compute section to SettingsPanel for the
/pro one-shot toggle.

* Single-row section under the existing Defaults block: button toggles
  arm / disarm — same path as the slash; auto-disarms after the next
  turn fires.
* Section header lives even with one row so D-4 (model picker) and any
  future compute knobs cluster here naturally.
* i18n: en + zh-CN strings for the section + row + helper note.

/harvest and /branch were originally bundled into this stage but
dropped — both are deletion-bound features.

Closes #430
2026-05-08 17:47:05 +08:00
YHH 042d2c4f6a feat(dashboard): server surface for /pro / /budget / /model / /loop (#429) (#434)
First stage of #428 (bucket-D of #369). Server-side plumbing for the
slash-only knobs the web should drive directly: /pro arming, /budget,
/model, and /loop. No SPA UI yet — D-2..D-5 each ship a panel section
against this surface.

* /api/settings GET exposes proNext / budgetUsd / model from live
  loop state alongside the existing persisted fields.
* /api/settings POST validates and routes each new field to the
  matching ctx.*Live callback. budgetUsd accepts null for "off".
* /api/loop/{status,start,stop} (new endpoint) drives the auto-resubmit
  timer the same way the /loop slash does. start enforces 5s..6h range
  and a non-empty prompt.
* DashboardContext gets matching callbacks: applyModelLive,
  setProNextLive, setBudgetUsdLive, getLoopRunStatus, startAutoLoop,
  stopAutoLoop.
* App.tsx wires each callback against existing live methods on
  CacheFirstLoop and the useLoopMode hook. No new state.

/harvest and /branch are deletion-bound features and are deliberately
out of scope here — see #428 follow-up.

Closes #429
2026-05-08 17:46:31 +08:00
YHH f8143f7098 feat(dashboard): viewer modal kind for /replay (#421) (#427)
Final stage of #416. /replay [N] now broadcasts a read-only viewer
modal to the web dashboard so the plan archive (summary + steps +
markdown body) is visible alongside the existing TUI scrollback card.

* New `viewer` member on the ActiveModal discriminated union — sibling
  to `picker`, but read-only: only `close` flows back through
  /api/modal/resolve. Splitting them keeps the picker schema clean
  rather than carrying empty `actions: []` on every viewer payload.
* useViewerBroadcast helper alongside usePickerBroadcast.
* App.tsx intercepts result.replayPlan, packages a snapshot
  (title with #idx/total · relative time, structured steps with
  done/queued status, markdown body, archive basename as meta), and
  feeds it to the hook. The existing log.showPlan call is unchanged
  so the TUI scrollback card still renders.
* SPA ViewerModal renders the steps as a list (✓ for done, · for
  queued) plus the markdown body and a single Close button.
* Server tests cover the dispatcher: close happy path, rejection of
  non-close actions, 503 when the resolver is not wired.

Wraps up bucket-C of #369. All five originally TUI-only pickers
(/resume, /restore, /mcp browse) plus the read-only /replay viewer
are now drivable from the web with no TUI keyboard required.
2026-05-08 13:33:33 +08:00
YHH 6fcf3a6cba feat(dashboard): wire McpMarketplace through picker protocol (#420) (#426)
* feat(dashboard): picker modal kind for web parity (#417)

First stage of #416 (bucket-C of #369). Lays the protocol primitives so
the five existing TUI-only pickers (SessionPicker, MCP marketplace,
checkpoint restore, plan replay, edit walkthrough) can be driven from
the web in C-2..C-5 without redoing transport work.

* New `picker` member on the `ActiveModal` discriminated union plus
  `PickerResolution` / `PickerAction` / `PickerItem` types.
* `POST /api/modal/resolve` dispatches `picker` actions: pick / delete /
  rename / new / install / uninstall / load-more / refine / cancel.
  `refine` and `load-more` keep the picker open so the TUI's filter
  state stays in lockstep across surfaces.
* Generic `PickerModal` SPA component with filter input, scrollable
  rows, action buttons gated on `modal.actions`, and rename / new
  inline forms.
* `usePickerBroadcast` hook + App.tsx-owned resolver / snapshot refs so
  C-2..C-5 picker components only need the hook to participate.

No picker is migrated yet — that arrives in C-2 (#418).

* feat(dashboard): wire SessionPicker through picker modal protocol (#418)

Second stage of #416. SessionPicker (the simplest existing TUI picker —
no pagination, no async, ~5 actions) now broadcasts modal-up snapshots
to the web dashboard and resolves pick / delete / rename / new / cancel
from /api/modal/resolve.

* SessionPicker.tsx accepts an optional `pickerPorts` prop bundle and
  feeds usePickerBroadcast a snapshot mapped from the existing sessions
  list (id=name, badge=branch, meta=turns/cost).
* App.tsx wires a memoized pickerPorts (broadcast + resolver/snapshot
  refs from C-1) and passes it to the picker mount.
* TUI keystroke handling unchanged — broadcast is purely additive.

Validates the C-1 (#417) protocol foundation against a real picker with
no dashboard-side regressions.

* feat(dashboard): checkpoint picker for /restore (#419)

Third stage of #416. Bare /restore (no name argument) now opens an
interactive CheckpointPicker — covering both the TUI side (the command
was text-only before) and the web dashboard via the picker modal
protocol from C-1.

* New CheckpointPicker.tsx mirrors SessionPicker shape: arrow-key
  navigation, ⏎ to restore, [d] to forget, esc to quit. Rows show
  short id + name + relative age + file count / size.
* /restore handler returns openCheckpointPicker:true when called with
  no argument; /restore <name|id> still resolves directly to keep the
  one-shot path working.
* App.tsx mounts the picker, gates hotkeys behind it, and routes
  outcome → restoreCheckpoint / deleteCheckpoint with a summary line
  on success.
* usePickerBroadcast wires the same modal-up/down + resolvePicker
  pipeline as C-2; web sees pick / delete / cancel actions.

* feat(dashboard): wire McpMarketplace through picker protocol (#420)

Fourth stage of #416. /mcp browse now broadcasts a picker snapshot to
the web dashboard with installed/source-tagged badges, popularity
meta, and the full action set: install / uninstall / refine /
load-more / cancel.

* installOrToggle split into doInstall + doUninstall so the web's
  explicit `install` / `uninstall` actions don't accidentally toggle
  on the wrong button (TUI Enter still toggles for keyboard
  ergonomics).
* Snapshot builder extracted to a pure
  `buildMarketplacePickerSnapshot` so the entry-to-item mapping can
  be unit-tested without mounting Ink + the registry network call.
* refine and load-more keep the picker open (per C-1 design); cancel
  closes it via the existing onClose hook.
* hasMore wired from `state.handle.cache.pagination.nextCursor` so
  the dashboard's "Load more" button only shows when more pages exist.
* McpHub forwards pickerPorts from App.tsx to the marketplace tab;
  the live tab is unchanged.
2026-05-08 13:19:39 +08:00
YHH 1934e2598c feat(dashboard): checkpoint picker for /restore (#419) (#425)
* feat(dashboard): picker modal kind for web parity (#417)

First stage of #416 (bucket-C of #369). Lays the protocol primitives so
the five existing TUI-only pickers (SessionPicker, MCP marketplace,
checkpoint restore, plan replay, edit walkthrough) can be driven from
the web in C-2..C-5 without redoing transport work.

* New `picker` member on the `ActiveModal` discriminated union plus
  `PickerResolution` / `PickerAction` / `PickerItem` types.
* `POST /api/modal/resolve` dispatches `picker` actions: pick / delete /
  rename / new / install / uninstall / load-more / refine / cancel.
  `refine` and `load-more` keep the picker open so the TUI's filter
  state stays in lockstep across surfaces.
* Generic `PickerModal` SPA component with filter input, scrollable
  rows, action buttons gated on `modal.actions`, and rename / new
  inline forms.
* `usePickerBroadcast` hook + App.tsx-owned resolver / snapshot refs so
  C-2..C-5 picker components only need the hook to participate.

No picker is migrated yet — that arrives in C-2 (#418).

* feat(dashboard): wire SessionPicker through picker modal protocol (#418)

Second stage of #416. SessionPicker (the simplest existing TUI picker —
no pagination, no async, ~5 actions) now broadcasts modal-up snapshots
to the web dashboard and resolves pick / delete / rename / new / cancel
from /api/modal/resolve.

* SessionPicker.tsx accepts an optional `pickerPorts` prop bundle and
  feeds usePickerBroadcast a snapshot mapped from the existing sessions
  list (id=name, badge=branch, meta=turns/cost).
* App.tsx wires a memoized pickerPorts (broadcast + resolver/snapshot
  refs from C-1) and passes it to the picker mount.
* TUI keystroke handling unchanged — broadcast is purely additive.

Validates the C-1 (#417) protocol foundation against a real picker with
no dashboard-side regressions.

* feat(dashboard): checkpoint picker for /restore (#419)

Third stage of #416. Bare /restore (no name argument) now opens an
interactive CheckpointPicker — covering both the TUI side (the command
was text-only before) and the web dashboard via the picker modal
protocol from C-1.

* New CheckpointPicker.tsx mirrors SessionPicker shape: arrow-key
  navigation, ⏎ to restore, [d] to forget, esc to quit. Rows show
  short id + name + relative age + file count / size.
* /restore handler returns openCheckpointPicker:true when called with
  no argument; /restore <name|id> still resolves directly to keep the
  one-shot path working.
* App.tsx mounts the picker, gates hotkeys behind it, and routes
  outcome → restoreCheckpoint / deleteCheckpoint with a summary line
  on success.
* usePickerBroadcast wires the same modal-up/down + resolvePicker
  pipeline as C-2; web sees pick / delete / cancel actions.
2026-05-08 13:19:12 +08:00
YHH 03804e6170 feat(dashboard): wire SessionPicker through picker modal protocol (#418) (#423)
* feat(dashboard): picker modal kind for web parity (#417)

First stage of #416 (bucket-C of #369). Lays the protocol primitives so
the five existing TUI-only pickers (SessionPicker, MCP marketplace,
checkpoint restore, plan replay, edit walkthrough) can be driven from
the web in C-2..C-5 without redoing transport work.

* New `picker` member on the `ActiveModal` discriminated union plus
  `PickerResolution` / `PickerAction` / `PickerItem` types.
* `POST /api/modal/resolve` dispatches `picker` actions: pick / delete /
  rename / new / install / uninstall / load-more / refine / cancel.
  `refine` and `load-more` keep the picker open so the TUI's filter
  state stays in lockstep across surfaces.
* Generic `PickerModal` SPA component with filter input, scrollable
  rows, action buttons gated on `modal.actions`, and rename / new
  inline forms.
* `usePickerBroadcast` hook + App.tsx-owned resolver / snapshot refs so
  C-2..C-5 picker components only need the hook to participate.

No picker is migrated yet — that arrives in C-2 (#418).

* feat(dashboard): wire SessionPicker through picker modal protocol (#418)

Second stage of #416. SessionPicker (the simplest existing TUI picker —
no pagination, no async, ~5 actions) now broadcasts modal-up snapshots
to the web dashboard and resolves pick / delete / rename / new / cancel
from /api/modal/resolve.

* SessionPicker.tsx accepts an optional `pickerPorts` prop bundle and
  feeds usePickerBroadcast a snapshot mapped from the existing sessions
  list (id=name, badge=branch, meta=turns/cost).
* App.tsx wires a memoized pickerPorts (broadcast + resolver/snapshot
  refs from C-1) and passes it to the picker mount.
* TUI keystroke handling unchanged — broadcast is purely additive.

Validates the C-1 (#417) protocol foundation against a real picker with
no dashboard-side regressions.
2026-05-08 13:18:46 +08:00
YHH b3f0aee657 feat(dashboard): picker modal kind for web parity (#417) (#422)
First stage of #416 (bucket-C of #369). Lays the protocol primitives so
the five existing TUI-only pickers (SessionPicker, MCP marketplace,
checkpoint restore, plan replay, edit walkthrough) can be driven from
the web in C-2..C-5 without redoing transport work.

* New `picker` member on the `ActiveModal` discriminated union plus
  `PickerResolution` / `PickerAction` / `PickerItem` types.
* `POST /api/modal/resolve` dispatches `picker` actions: pick / delete /
  rename / new / install / uninstall / load-more / refine / cancel.
  `refine` and `load-more` keep the picker open so the TUI's filter
  state stays in lockstep across surfaces.
* Generic `PickerModal` SPA component with filter input, scrollable
  rows, action buttons gated on `modal.actions`, and rename / new
  inline forms.
* `usePickerBroadcast` hook + App.tsx-owned resolver / snapshot refs so
  C-2..C-5 picker components only need the hook to participate.

No picker is migrated yet — that arrives in C-2 (#418).
2026-05-08 13:17:58 +08:00
YHH 1ffa27975c docs(issues): split off display/rendering template, collect terminal host (#412) (#413)
Flicker reports keep landing without the one piece of info that
discriminates the bug — the terminal host (VSCode/Cursor xterm.js vs
standalone Windows Terminal / iTerm2 / etc.). The shell field on the
generic bug template was the wrong axis. New template asks for host +
version, env vars (TERM_PROGRAM/VSCODE_INJECTION/WT_SESSION), and ships
copy-paste diagnostic snippets so users do not have to know how to
introspect their own terminal.
2026-05-08 10:18:01 +08:00
YHH d63509ad85 refactor(ui): quiet chat-screen chrome (#411)
Two stacked rules + five-segment status row + per-card breathing room
made history scroll read as scattered fragments. Tighten three things:

- Settled cards drop the marginTop the active form keeps, so scrollback
  collapses to flat lines instead of one blank between every card.
- ModeStatusBar drops its dashed rule; StatusRow's solid rule already
  separates the chat from bottom chrome.
- StatusRow drops session-cost + wallet segments (StatsPanel and
  UsageCard already surface those); only turn cost + cache stay.
- Settled CardHeader filters faint string meta (1.2KB / 2.34s / exit N
  on OK cards) and drops the right slot so spinners don't linger in
  scrollback. Colored badges (rejected, retry) still render.

ui-status-row-balance reduced to the cells StatusRow now renders;
wallet/session currency coverage stays in ui-stats-panel-currency
and ui-usage-card-balance.
2026-05-08 09:45:50 +08:00
YHH 459a370b7c docs(readme): canonical install + subcommand cheatsheet (#408) (#409)
The Install block dropped readers at `npx reasonix code` with no
mention of the global-install path, and the README never named the
five subcommands a new user actually has to choose from. The website
already has a CLI panel but the section had no anchor, so the README
couldn't link to it.

- Name `npx` as the recommended path; point users at `reasonix update`
  if they want the global install.
- Add a five-row cheatsheet (code / chat / run / doctor / update) so
  newcomers don't have to guess. Defer the long tail (replay / diff /
  events / stats / index / mcp / prune-sessions) to `--help` and the
  CLI panel on the website.
- Mirror in zh-CN.
- Add `id="cli"` to the website's CLI section so the README link
  resolves.
2026-05-08 09:11:08 +08:00
Wesley Viana 39e69c2cab chore: improve loop.ts tests (#271)
* chore: improve loop.ts tests

* undo uncessary - change

* drive auto-escalation tests through step() instead of private-field access
2026-05-08 08:52:55 +08:00
ctharveyandCharles H 0d89ab84ef test(mcp): cover startup summary states (#396)
Co-authored-by: Charles H <[email protected]>
2026-05-08 08:43:47 +08:00
YHH ecb1bfb051 fix(prompt): inline short single-line pastes verbatim (#397) (#406)
Bracketed-paste content was always converted to a `[paste #N · …]`
sentinel chip, even when the user pasted a single word or a number.
That made the composer feel hostile to common one-shot paste usage —
copying an identifier or a URL out of another window and dropping it
mid-sentence shouldn't leave a chip in the buffer.

Inline the content verbatim when it has no newline AND fits under
INLINE_PASTE_THRESHOLD (200 chars); fall back to the sentinel chip
for anything longer or multi-line. The model still receives the full
paste text either way — this is display-only.
2026-05-08 08:40:34 +08:00
YHH db2ca97eb0 fix(permissions): match Windows project keys case-insensitively (#402) (#405)
projects[] is keyed by absolute rootDir, so on Windows two sessions
that resolve the same physical directory to different drive-letter or
component casing (PowerShell's `F:\Reasonix` vs bash/MINGW's
`f:\reasonix`) miss the persisted "always allow" entry from the prior
session and re-prompt every turn.

Add findProjectKey() that prefers the exact match and, on win32,
falls back to a case-insensitive scan. Use it in load / add / remove
/ clear so any-cased rootDir consolidates onto the existing key
instead of forking a duplicate. Non-Windows lookups remain
case-sensitive — Linux paths really are.
2026-05-08 08:40:15 +08:00
YHH 47f207119f fix(ui): refresh model badge on dashboard preset change and /pro turns (#403) (#404)
Two paths bypassed the cards' model snapshot:
- Dashboard preset change (POST /settings → applyPresetLive) reconfigured
  the loop but never dispatched session.model.change, so subsequent
  reasoning/streaming cards still rendered the old model badge.
- /pro consumed a single-turn escalation without telling the cards, so
  the armed turn ran on v4-pro but the badge stayed on the base model.

applyPresetLive now dispatches session.model.change. reasoning.start /
streaming.start carry an optional model id; the loop's currentCallModel
getter feeds it through TurnTranslator.flushBuffers so the badge tracks
the model that actually produced the output.
2026-05-08 08:39:51 +08:00
ctharveyandCharles H 6ccca2be0b fix(search): honor abort during recursive fs scans (#400)
Co-authored-by: Charles H <[email protected]>
2026-05-08 08:14:54 +08:00
75 changed files with 5619 additions and 489 deletions

No files matched your search

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