Compare commits

...
65 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
YHH f422a94dfa chore(release): 0.30.5 (#395)
Three contributor-led follow-ups: storm guard exempts safe inspector
reads (#388 by @ctharvey), wait_for_job replaces polling loops with
one blocking call (#390 by @ctharvey), and /skill new finally
provides the missing scaffold for user skills (#394).
2026-05-08 00:51:13 +08:00
YHH ee5961fe70 feat(skills): /skill new scaffolds a stub + README walkthrough (#394)
* feat(skills): /skill new scaffolds a stub + README walkthrough (#366)

User feedback: wanted to install a frontend-writing skill, "no
discoverable entry point". The /skill list empty state told users
WHERE skills live but not HOW to author one — and there's no remote
registry to install from.

Adds the missing creation step:

- /skill new <name>          → writes <project>/.reasonix/skills/<name>.md
- /skill new <name> --global → writes ~/.reasonix/skills/<name>.md

The stub carries the minimal frontmatter (name + description) plus
a comment block listing the optional knobs (runAs, allowed-tools,
model). Refuses to overwrite existing files; rejects invalid names
via the existing isValidSkillName regex.

Empty /skill list now surfaces the new scaffold + an explicit "no
remote registry yet" line so users don't keep hunting for a
marketplace that doesn't exist. Catalog + README in EN + zh-CN
gain the "author your first skill" path. The /skill list footer
mentions /skill new alongside show / run.

* fix(skills): atomic create with wx flag — close TOCTOU race CodeQL flagged

CodeQL flagged the existsSync→writeFileSync sequence as a potential
file-system race. Switch to writeFileSync with `flag: "wx"` so the
existence check IS the atomic write — if a parallel process landed
the file between our check and write, EEXIST surfaces and we report
the conflict instead of silently overwriting.

Keep the folder-form pre-check for a cleaner error message when the
collision is on the dir-style skill (`<name>/SKILL.md`).
2026-05-08 00:31:22 +08:00
ctharveyandCharles H 15b4060e68 feat(jobs): add wait_for_job tool for background jobs (#390)
Co-authored-by: Charles H <[email protected]>
2026-05-08 00:10:09 +08:00
ctharveyandCharles H 9ec5b99e07 feat(storm): add stormExempt flag to exempt obvious reads from the loop guard (#388)
Co-authored-by: Charles H <[email protected]>
2026-05-08 00:09:21 +08:00
YHH e93c4ffde3 chore(release): 0.30.4 (#392)
Sweep of the 0.30.2 / 0.30.3 launch-day bug + onboarding queue:
resume now restores cache+context+cost (#364), model pill follows
/model + /preset switches (#372), bare /model opens an interactive
picker (#371), three more Shift+Tab encodings for PowerShell /
modifyOtherKeys / Kitty (#373), defensive stdin drain on exit kills
the fish/bash escape-leak (#365), and --dir / pinned workspace get
surfaced in the welcome banner, /status, sandbox errors, and the
README (#370).
2026-05-08 00:04:33 +08:00
YHH 0e4cba3670 fix(tty): drain pending feature-detection replies on exit (#365) (#391)
Reporter saw `^[]11;rgb:...^[\^[[33;1R^[[?62;1;4c` printed by fish /
bash right after exiting reasonix. Those bytes are RESPONSES — OSC 11
bg-color, CPR cursor-position, DA1 device-attributes — sent by the
terminal in reply to queries the runtime / a dep emitted during
session startup. The dist bundle has none of those query bytes
itself, so the source is bun's tty feature-detection path or a
transitive dep doing color-scheme detection.

Whoever sent them, the responses sat in stdin's queue until reasonix
exited. The parent shell then read them and rendered the bytes as
input.

Add `drainTtyResponses(50ms)` and call it in chat.tsx's exit finally,
after `runtime.closeAll()`. Best-effort: sets raw mode, reads-and-
discards anything still queued for 50ms, restores cooked. No-op when
stdin isn't a TTY or raw mode isn't available.

0.30.3's alt-screen default is the upstream mitigation (DECSET 1049
flushes the input queue on most terminals); this is the program-side
mitigation that's independent of which terminal the user is in.
2026-05-07 23:53:35 +08:00
YHH d0b251d43a feat(ui): surface --dir / pinned workspace for first-time users (#370) (#389)
User feedback (multiple data points): beginners hit the default
cwd-scoped behavior and conclude Reasonix can't read folders other
than where they launched it. The capability already exists via
`reasonix code --dir <path>`, but nothing in the UI tells you so
until you read --help.

Three onboarding surfaces now make it discoverable:

- WelcomeBanner shows a "▸ workspace · /path  (relaunch with --dir
  <path> to switch)" line in code mode. Visible only on the empty
  state, so it doesn't survive past the first turn.

- `/status` includes a workspace line:
    workspace /path · pinned at launch (relaunch with --dir <path>
    to switch)

- The filesystem sandbox-escape error now points the user at --dir
  instead of just dropping a raw "path escapes sandbox root":
    path escapes sandbox root (X): Y — workspace is pinned at
    launch; quit and relaunch with `reasonix code --dir <path>` to
    work in a different folder

Plus a Getting Started callout in both READMEs explaining --dir and
why mid-session switching isn't a thing (message log + memory paths
get tangled with stale roots, so the design pins at launch).

No new slash command, no /cwd. The agent's system prompt already
says mid-session switching was removed by design.
2026-05-07 23:37:39 +08:00
YHH daebf3d571 feat(ui): /model with no arg opens an interactive picker (#371) (#387)
`/model <id>` required typing the model id by hand and `/models` was
a separate read-only listing. New users had no obvious way to switch
without prior knowledge of the catalog.

Bare `/model` now opens a modal picker that mirrors SessionPicker's
shape: arrow-key list, ⏎ confirm, [r] refresh the API catalog, esc
cancel, current model marked. The list seeds from the live DeepSeek
catalog (`useSessionInfo` already fetches it on mount); when the
catalog hasn't loaded yet the picker falls back to the four known
DeepSeek ids so it's never empty on first open. The current model
is always included even when the API didn't return it.

Confirming a row routes through `loop.configure({ model })` AND the
`session.model.change` event added in #372 — so the badge on the
NEXT card pill reflects the new selection.

`/model <id>` typed entry continues to work for power users.
2026-05-07 22:54:33 +08:00
YHH 28119cad01 fix(input): recognize PowerShell / modifyOtherKeys / Kitty Shift+Tab variants (#373) (#386)
Recognized only the bare xterm `\x1b[Z` back-tab. PowerShell 7.x on
some Windows hosts emits the modifier-encoded `\x1b[1;2Z`; modern
Windows Terminal in modifyOtherKeys mode (which we enable on
startup) emits `\x1b[27;2;9~`; the Kitty keyboard envelope is
`\x1b[9;2u`. None of these matched, so the edit-mode cycle was
silently dropped on PowerShell.

Add all three variants to `stdin-reader.ts` CSI_TAIL_MAP and to the
Ink-fallback `key-normalize.ts` recovery table. `/mode` slash command
remains as a typed fallback for any future terminal that emits a
form we haven't seen yet.
2026-05-07 07:10:06 -07:00
YHH 3c24c0f1e1 fix(ui): /model + /preset update active model so the next card pill is fresh (#372) (#385)
`state.session.model` was set once in `initialState()` and never
mutated. `/model <id>` and `/preset {auto,flash,pro}` only routed
through `loop.configure({ model })`, never into the agent store —
so the next `streaming.start` / `reasoning.start` / "thinking · X"
card snapshotted the launch-time model id, even when the model that
actually answered the turn was different.

Add a typed `session.model.change` event the model handlers dispatch
through `ctx.dispatch`. Reducer mutates `state.session.model` (with
referential identity preserved when the id is unchanged) and the
existing snapshot logic in `makeReasoningCard` / `makeStreamingCard`
picks up the new value automatically. Cards already opened keep
their captured model — that snapshot is intentional so mid-turn
escalation doesn't relabel completed reasoning.
2026-05-07 07:04:04 -07:00
YHH 7edcb07465 fix(stats): persist cache totals + lastPromptTokens across resume (#364) (#384)
`SessionMeta` only carried `totalCostUsd` and `turnCount`, so on every
resume `/status` showed:

- 0 context — `lastPromptTokens` came from `turns[0]?.usage`, and
  `turns` is empty before the first live turn fires
- 0% cache hit — `aggregateCacheHitRatio` summed `turns[]` only

The next API call would still read the cached prefix and answer
correctly (turn 1 in the user's log shows 366,976 / 367,085 cache
hits), but the panel reported "no context" until that call landed.

Persist the missing fields after each turn (`patchSessionMeta`) and
seed them on resume (`stats.seedCarryover`). `aggregateCacheHitRatio`
and `summary().lastPromptTokens` now fall through to the carryover
when no live turn has run yet.
2026-05-07 06:46:18 -07:00
YHH 04b12e9f2d chore(release): 0.30.3 (#382)
Ink 7 + React 19 chat scroll rewrite (#380), configurable web_search
backend with SearXNG (#338), MCP filesystem sandbox preflight + wizard
mkdir confirm (#362, PR #379), README URL fix (#375), bug template
update (#378).
2026-05-07 06:29:05 -07:00
YHH b07b86ee2f feat(ui): row-precision virtual scroll on Ink 7 + React 19 (#380)
* feat(ui): row-precision virtual scroll on Ink 7 + React 19

Bump ink 5.2→7.0.2 + react 18.3→19.2; drop react-reconciler/yoga-layout.
Retire src/renderer/ (cell-diff + ink-compat shim) — Ink 7 ships native
equivalents for every motivation: alternateScreen, useAnimation,
useWindowSize, useBoxMetrics, synchronized output, incrementalRendering.

Default to alt-screen so the resize-ghost / scroll-yank class of bugs
disappears. `--no-alt-screen` opts back into scrollback for users who
need chat output preserved in shell history on exit.

Row-precision virtual scroll with reading mode: scrolling away from
bottom hides PromptInput and replaces it with a "📖 reading history —
End / PgDn to return" indicator. PgUp/PgDn/End owned by App; ↑/↓ on
an empty buffer hand off to chat scroll via PromptInput; App takes ↑/↓
directly when busy or in reading mode (PromptInput can't process them).
Wheel events translate to ↑/↓ in raw mode, so the same paths cover wheel.
Ctrl+P/N keeps the prompt-history binding (wheel-immune).

Drop <Static> — incompatible with alt-screen (no scrollback to shelter
committed cards across resize-reflow). React.memo on CardRenderer plus
the reducer's reference-stable card array skips reconciler cost on
unchanged cards. CardStream renders all cards inside an overflow:hidden
Box; inner Box's marginTop=-scrollRows shifts content; useBoxMetrics
reports inner/outer heights so App can clamp scroll bounds.

CardStream.isSettled covers task / plan / subagent kinds. PromptInput's
useReserveRows quantizes to 4-row buckets so per-keystroke layout doesn't
churn. TickerProvider migrated to Ink 7's useAnimation. Modal-open
condition collapsed from 14 OR'd checks into one reused boolean.

Migrate inline tests (markdown, plan-confirm) off the cell-diff renderer
to ink-testing-library. Remove dead alt-screen.ts; strip stale comments
referencing removed scrollback-mode workarounds.

* test(ui): provide fake stdin for Ink 7 raw-mode check in CI

Ink 7 calls setRawMode on stdin during render(); CI's process.stdin
isn't a TTY so it throws "Raw mode is not supported". Pass a minimal
EventEmitter-based fake stdin (setRawMode no-op, isTTY=true) alongside
the existing fake stdout. Affects four tests that import render directly
from ink for in-process render-and-assert: ui-session-picker-currency,
ui-stats-panel-currency, ui-usage-card-balance, ui-status-row-balance.

* test(ui): extract makeFakeStdin/Stdout to shared tests/helpers/ink-stdio
2026-05-07 06:14:37 -07:00
dacec354 7ba3d086ff chore(issue-template): add shell/terminal fields, update model examples (#378) 2026-05-07 05:33:20 -07:00
YHH 1a7d74f3f0 fix(mcp): preflight filesystem sandbox dir + offer mkdir in wizard (#362) (#379)
The official @modelcontextprotocol/server-filesystem refuses to start
when its sandbox directory is missing and prints a raw Node stack
trace, leaving the user without an actionable next step.

- preflightStdioSpec validates every positional path arg before
  spawn and throws a one-line error: "create it with: mkdir -p '<path>'"
- The first-run / reconfigure wizard now stat()s the directory the
  user typed for filesystem MCP and offers Y/Enter to mkdir -p,
  N/Esc to enter a different path.
2026-05-07 05:31:09 -07:00
dacec354 0f7dcd026a fix(readme): correct website URLs — /reasonix/ → /DeepSeek-Reasonix/ (#375) 2026-05-07 04:43:08 -07:00
dacec354 12998d246d feat(web): configurable web_search backend with SearXNG support (#338)
Adds SearXNG as an alternative web_search engine, switchable via /search-engine slash command or ~/.reasonix/config.json. Mojeek stays the default — zero risk for existing users.
2026-05-07 02:02:06 -07:00
YHH 43d437fd38 chore(release): 0.30.2 (#361) 2026-05-06 23:06:55 -07:00
YHH 62813e3d6d fix(dashboard): cost displays follow wallet currency, not hardcoded USD (#360)
CLI shows session cost in the wallet's currency (¥ for CNY wallets,
$ for USD), but the dashboard always used `fmtUsd()` which prints
the raw stored USD with a `$`. CNY users saw `$0.5190` in the
dashboard for the same session that read `¥0.024` in the CLI — both
the symbol AND the magnitude diverged because no conversion happened.

- New `fmtCost(usd, currency, fractionDigits?)` helper in
  `dashboard/src/lib/format.ts`. Mirrors the CLI's `formatCost()` —
  CNY converts via `USD_TO_CNY = 7.2`, USD passes through, undefined
  defaults to CNY.
- `overview.ts` current-session cost + cost-trend day average now
  thread `c.balance?.currency` into `fmtCost()`.
- `chat.ts` SideRail cost / budget rows and ChatStatusBar
  turn / session cost rows pull currency from `stats.balance[0]`.
- Existing `fmtUsd()` kept as-is for places where USD is intentional
  (Claude-equivalent comparisons in usage.ts) — those stay USD because
  Claude's API is USD-priced regardless of the user's wallet.
2026-05-06 23:00:56 -07:00
YHH a29a0ac157 feat(ui): ctrl-o expands the live streaming card to full tail (#335, #337) (#359)
Pain point @dacec354 raised twice: while the model writes a long plan
or todo list, the streaming card only shows the last 4 lines, so the
content scrolls past before you can read it. And once a long reply
settles, you may want to fold it back down — but `<Static>` already
flushed the bytes to scrollback so that half can't be unwound.

This collapses both requests into one keystroke: `ctrl-o` toggles
"expanded" mode on the live streaming card. Expanded shows the last
60 visual lines (capped so the card can't swallow the whole viewport)
plus a `⋯ N earlier lines above` hint when content overflows.
Collapses back to the 4-line preview. Auto-resets to collapsed at the
end of every turn so each new reply starts compact.

The same `(LIVE)` constraint covers #337's fold-toggle ask: only
not-yet-committed cards are addressable. Settled cards live in Ink's
`<Static>` which writes terminal scrollback once, no take-backs.

The `expanded ⌃o` / `preview ⌃o` pill in the card header advertises
the keybind so users discover it without reading docs.
2026-05-06 22:48:35 -07:00
YHH ccdbfb3a28 fix(ui): reasoning spinner survived reasoning.end when a later card existed (#358)
`splitCardStream` only treated the LAST card as live; everything before
it was committed to Ink's `<Static>`. When the model streamed reasoning
THEN content (or kicked off a tool card), the reasoning card was
no longer last — so it got frozen into `<Static>` while still
`streaming: true`. `reasoning.end` later set `streaming: false` on
the cards array, but `<Static>` doesn't re-render frozen items, so the
"reasoning…" header kept its spinner indefinitely.

Fix: live-vs-committed split now scans for the FIRST unsettled card
and keeps everything from there onward in the live tree. Cards only
move into `<Static>` once they're settled AND every earlier card is
too — preserving the in-order append contract `<Static>` requires.

`suppressLive` semantic preserved: still hides the trailing live card
when the modal owns the screen.

Two new regression tests cover the reasoning + streaming case (must
stay live) and the all-settled case (must commit in order).
2026-05-06 22:33:06 -07:00
YHH 1805b0c647 fix(ui): SessionPicker honors wallet currency instead of hardcoded ¥ (#312) (#357)
Same bug shape as the StatusRow / UsageCard / StatsPanel set that #272
already fixed — `SessionPicker` was still doing
`¥${totalCostUsd * USD_TO_CNY}` so USD-wallet users saw ¥-formatted
costs in the session list.

- `SessionMeta` gains `balanceCurrency?: string`. App.tsx writes the
  live wallet currency alongside `totalCostUsd` on each turn save, so
  every persisted session knows what currency its cost is denominated
  in.
- `SessionPicker` accepts a `walletCurrency` prop and falls back to
  each row's `meta.balanceCurrency`. Prop wins (live value > stored).
  In-app picker (`/sessions`) gets the live ref from App; pre-mount
  picker in `chat.tsx` doesn't have a balance fetched yet, so it
  relies on the stored per-session value.
- Cost rendering routes through the shared `formatCost()` helper that
  the rest of the cost UI already uses.
- Regression test asserts USD wallet → `$0.05`, CNY wallet → `¥0.36`,
  no-prop + stored-USD → `$0.05`, neither → unchanged `¥0.36`.
2026-05-06 22:22:11 -07:00
YHH 4032930fed feat(ui): live tokens/s on streaming reply + space pauses 5s undo (#334, #337) (#356)
- StreamingCard renders a `42 t/s` pill in the live header and a
  `1.2k tok · 42 t/s` summary on the settled card. Computed via the
  bundled DeepSeek tokenizer; gated below 4 tokens / 500 ms so the
  pill never shows obviously-bogus rates while the first chunk lands.
  Re-renders piggyback on the slow tick so the rate keeps updating
  even when chunks stall briefly.
- The auto-mode undo banner now accepts a `space` keystroke to pause
  the 5-second countdown indefinitely. While paused the bar freezes
  at the captured fraction, the badge swaps to "Ns · paused", and
  pressing space again resumes from where it stopped. The hint row
  on edit-mode entry mentions both `u` and `space`.

The `u` and `space` keybinds share the same modal-and-prompt-empty
gating, so neither steals input while the user is typing.
2026-05-06 22:08:19 -07:00
YHH b03e11bf41 chore(release): 0.30.1 (#355) 2026-05-06 21:15:07 -07:00
ctharveyandCharles H 0b91250c44 fix(ui): suppress live card redraw while picker modal is open (#353)
Co-authored-by: Charles H <[email protected]>
2026-05-06 21:09:55 -07:00
YHH 5bcc3b6389 fix(renderer): drop cell-diff aliasing, run prod on real Ink (#346) (#354)
Aliasing `from "ink"` → ink-compat in the CLI bundle made every
TUI bug invisible to `npx tsx` repros and forced cell-diff regressions
onto users only. Issue #346 (ShellConfirm ghost rendering) was a pure
cell-diff bug; the cleanest fix is to remove the alias and let
production take real Ink the same way source mode does.

- tsup: drop the `ink` alias and `noExternal` for ink/ink-text-input;
  bundle keeps `from "ink"` external
- package.json: ink + ink-text-input move to runtime dependencies
- markdown-view: switch the only `inkCompat.*` namespace user back to
  `import { Box, Text } from "ink"`
- bundle-smoke: invert the alias assertion to expect ink stays external

useCursor() in PromptInput keeps its cell-diff import; without a
CursorContext provider it no-ops, so it's harmless under real Ink.
2026-05-06 21:01:59 -07:00
YHH 0f7f7c24c2 docs(benchmarks): real-world cache-hit case study (#351)
Anonymized DeepSeek dashboard from a real Reasonix user
(2026-05-01, used with permission): 435M input tokens, 99.82%
cache hit, ~$12/day on v4-flash vs ~$61 the same workload would
cost without the cache.

Lives at benchmarks/real-world-cache/ alongside an explanation of
the four mechanisms (ImmutablePrefix + AppendOnlyLog +
VolatileScratch + auto-compact) that produce the hit rate. README
gets a NOTE callout pointing at it — answers the recurring "isn't
this just DeepSeek's prefix cache?" pushback with concrete numbers
instead of a re-derived napkin.
2026-05-06 18:54:57 -07:00
YHH 6379bc0628 chore(release): 0.30.0 (#349) 2026-05-06 18:06:28 -07:00
YHH 82c5627123 fix(renderer): use CHA absolute for column moves in cell-diff (#346) (#348)
Switch the X-axis branch of `moveTo()` from CUF (relative cursor right,
`\x1b[NC`) to CHA (absolute column, `\x1b[N+1G`). Y-axis stays on
CUU/CUD since we don't track absolute terminal rows.

Relative column moves accumulate drift across frames: if any earlier
write miscounted cell width — wide-char disagreement (`▸` U+25B8 in
"narrow" fonts that render 2-cell), `▎` ambiguous-width on terminals
that font-detect East Asian width, OSC8 hyperlinks parsed as visible
chars, etc. — the cursor's actual column desyncs from what the
renderer thinks. The next CUF lands at the wrong column, ghost-rows
get written into hint lines, and the modal "shifts" as users navigate
(symptom in #346 issue screenshots).

Anthropic's Claude Code TUI hit the same class of bug
(anthropics/claude-code#14208) and the fix was the same: prefer CHA
over CUF/CUB so column targeting is immune to per-cell width
miscounts. Fix is invisible on terminals where our width math was
already correct; on terminals where it wasn't, no more accumulating
drift.
2026-05-06 18:02:08 -07:00
YHH e991644416 feat(slash): first-class command aliases (#332) (#347)
Add `aliases?: readonly string[]` to SlashCommandSpec so /quit + /q
resolve to /exit, /? to /help, /reset to /new, /lang to /language —
all from a single declaration on the spec instead of ad-hoc handler
mirrors scattered across handlers/basic.ts + handlers/language.ts.

dispatch, suggestSlashCommands and detectSlashArgContext all route
through one resolveSlashAlias() built from SLASH_COMMANDS at module
init. Suggestion rows surface aliases dimly (` · /quit /q`) so they
stay discoverable without polluting the autocomplete list with
duplicates. Dashboard /api/slash response now exposes the field too.
2026-05-06 18:01:54 -07:00
YHH 39dbe19bcd chore(release): 0.29.1 (#345) 2026-05-06 17:07:23 -07:00
YHH c1e8a933d3 fix(markdown): row-group the table fallback layout (#340) (#344)
When a markdown table is too wide for the viewport, FallbackTable
flattens it into key/value lines. The previous implementation
iterated headers first, then rows-within-each-header — so a 3×N
table came out as N "Component:" lines, then N "What:" lines,
then N "Manual TCs:" lines. The user couldn't tell which value
belonged to which row.

Reproduction (issue #340): a planning table for 9 components
rendered as 9 grouped Component lines, 9 grouped What lines,
9 grouped Manual TCs lines — visually a list, not a table.

Swap loop order: iterate rows first, then write all columns
inside each row block, separated by a blank row between rows.
Output now reads top-to-bottom as one logical row at a time.
2026-05-06 16:55:20 -07:00
YHH b401913910 fix(plan): show plan body in PlanConfirm modal (#336) (#343)
When the model called submit_plan with a markdown body but no
structured steps, the Approve plan modal showed only the
warning banner + the choice list — the actual plan content
was hidden behind the collapsed tool-call card in scrollback.
Users had no way to see what they were approving without
hunting up through the transcript.

- Render the plan body via MarkdownView when no steps are
  supplied. Steps still take precedence when present (they
  are the canonical structured view).
- Cap inline preview at 24 lines; surface "… N more lines
  above in scrollback" when the body exceeds that. Reserve
  budget grows with whichever body section is being shown.

Tests cover the three branches: body-only, steps-present,
and truncated-body-with-overflow-hint.
2026-05-06 16:44:59 -07:00
YHH 5662b57a4b fix(stats): carry session cost / turn count across resume (#333) (#342)
The TUI's session-cost figure ($X session) reset to $0 on every
resume because SessionStats was rebuilt empty even when the disk
meta still held the cumulative totalCostUsd from prior runs.

Reproduction (issue #333): hello → exit → resume → expense
accumulator displays 0 instead of the prior session total.

The persistence path was already correct — App.tsx writes
`{ totalCostUsd, turnCount }` into the session meta after every
turn. Only the read-back was missing.

- SessionStats gains a `seedCarryover()` once-at-construction hook
  that adds prior totals to the in-memory totalCost / summary.turns
  without polluting the live `turns[]` array (which intentionally
  holds only this run's per-turn records).
- CacheFirstLoop now calls `loadSessionMeta()` on resume and seeds
  the carryover when prior messages exist. Fresh sessions are
  unaffected — undefined meta fields short-circuit the seed.

Tests cover the carryover math (telemetry.test.ts) and the
end-to-end loop wiring (session.test.ts).
2026-05-06 16:38:13 -07:00
YHH e40e1911cc fix: issue #330 — markdown osc8 garbage + cell-diff shrink-row leak (#341)
* fix(markdown): stop linkifying "e.g" / "i.e" / drop OSC 8 garbage (#330)

Two coupled bugs in the markdown file-ref linkifier:

1. FILE_REF_RE matched English abbreviations because the extension
   class was `{1,6}` — `e.g`, `i.e`, `a.m` all qualified.
2. `osc8()` baked OSC 8 escape bytes (\x1b]8;;...\x07) into Text
   content. The cell-diff renderer's wrapLine strips zero-width chars
   (ESC, BEL) but keeps the printable body, so terminals received
   plain `]8;;file://e.ge.g]8;;` characters — visible garbage on
   any terminal, with no working hyperlink anywhere.

The fix:

- Tighten FILE_REF_RE post-match: require either path-shape (slash),
  line-number suffix, or extension >= 2 chars. `src/foo.ts`,
  `package.json`, `util.c:42` still match; bare `util.c` no longer
  does (acceptable — rare in prose).
- Strip the OSC 8 escape from `osc8()` — keep color + underline
  styling so file refs stand out, but do not emit hyperlink bytes.
  The renderer would need per-span hyperlink propagation through
  nested HostText flattening to support real OSC 8; that's a
  separate refactor. Until then, no link beats broken link.
- Plumb a `hyperlink` prop on ink-compat Text so the future
  per-span fix has a place to land.

Regression test mounts Markdown through the reconciler and asserts
the byte stream never contains `]8;;` for `e.g` / `i.e` samples.

* fix(renderer): trail-clear shrunk rows in cell-diff (#330)

Defensive against terminal-state desync. The cell-diff renderer
skips cells where prev and next are byte-equal — including the
trailing EMPTY cells of a row that shrank. If the terminal's
actual state diverged from prev (e.g. a stray ESC body slipped
through earlier rendering), those skipped trailing cells retain
stale chars from prior frames.

This was the second half of issue #330: with the markdown OSC 8
garbage previously sourcing the desync, the option-list modal
rendered "allow always" + "mand, ask again next time" leftover
on rows that originally held a longer hint.

- New `clearToEOL` patch type → serializes to `\x1b[K`
- After diffEach, sweep rows: if next's row ends earlier than
  prev's content extent, emit clearToEOL past the last visible
  cell. Bounded — at most one clearToEOL per row that shrank.
- Two regression tests cover the modal-shrink case and verify
  no clearToEOL is emitted for same-width swaps.
2026-05-06 16:32:00 -07:00
YHH fdea3bb837 fix(dashboard): wire up filter chips + mark static indicators (#331)
The .chip-f class has cursor:pointer + a hover style, so every chip
looked clickable. In skills, plans, and hooks the chips were laid out
as filter buttons (one hardcoded "active") but had no onClick — clicks
did nothing. Other panels reused chip-f for non-interactive count
indicators, which had the same misleading hover affordance.

Real filters wired:

- skills: All / Project / Global / Builtin filters the list by scope
- plans: All / Active / Done filters by completion status
- hooks: Resolved + each event filters the matrix to rows whose hooks
  fire on the selected event (matrix columns still show all events)

Static indicators marked with the new `chip-f.static` modifier (cursor
default, no hover lift). Sessions count, permissions Project/Builtin
counts, usage records/log size, tools total + plan-mode flag, chat
mode label, semantic index status, and semantic config-tag chips all
no longer pretend to be clickable.

CSS: new `.chip-f.static` + `.chip-f.static:hover` rules disable the
interactive affordances without duplicating the chip styling.
2026-05-06 08:58:58 -07:00
231 changed files with 8855 additions and 9563 deletions

No files matched your search

+12 -5
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,10 +18,13 @@ What you expected to happen.
Steps or minimal code that reproduces it.
**Environment**
- Reasonix version:
- Node version:
- OS:
- DeepSeek model (e.g. `deepseek-chat`, `deepseek-reasoner`):
- Reasonix version (`reasonix --version`):
- Node version (`node --version`):
- OS (Windows 11 / macOS 14 / Ubuntu 24.04 / …):
- Shell (bash, zsh, fish, PowerShell 7, PowerShell 5.1, cmd, …):
- Terminal app (Windows Terminal, iTerm2, Alacritty, kitty, WezTerm, **VSCode integrated**, **Cursor integrated**, Hyper, …):
- DeepSeek model (e.g. `deepseek-v4-flash`, `deepseek-v4-pro`):
**Logs / transcript**
If using the CLI, attach the relevant chunk of `--transcript`.
If using the CLI, attach the relevant chunk of `--transcript`, or run
`reasonix doctor` and paste the output.
+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.
+2
View File
@@ -33,4 +33,6 @@ sessions/
.reasonix/semantic-skip
# Scratch entry regenerated each time scripts/bundle-codemirror.mjs runs.
scripts/.cm-entry.mjs
# Personal bun lockfile — project uses npm officially.
bun.lock
+484
View File
@@ -3,6 +3,490 @@
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.31.0] — 2026-05-08
**Headline:** a Mac user reported a DeepSeek 503 day where Reasonix
showed a wall of raw `DeepSeek 503: <html>...` and they couldn't tell
if our agent had crashed or the upstream API was down. Two threads of
work fell out of that single bug: a friendly outage notice with a
1.5s reachability probe to `/user/balance` (so we can say "DS main
API answered, but /chat/completions is failing — their problem") and
a full sweep of every hardcoded English string a Chinese user could
hit. ~150 strings across 8 files moved into the `loop.*` / `errors.*`
/ `app.*` / `hooks.*` / `summary.*` / `wizard.*` namespaces with
zh-CN translations. The setup wizard now opens with a language picker
defaulting to `detectSystemLanguage()` — for the case where
`Intl.DateTimeFormat().resolvedOptions().locale` returns the wrong
locale and a user shouldn't have to discover `/language` after
finishing setup in English.
The other half of the release is dashboard parity work — picker
modals (sessions / checkpoint / MCP marketplace), viewer modal for
`/replay`, plus cockpit / budget gauge / model picker / loop control
panel / `/pro` one-shot — closing buckets B-E of the #369 web-parity
tracker.
**Features:**
- feat(loop): friendly DeepSeek 5xx error with reachability probe.
When the chat endpoint returns 5xx (after retry.ts has already
retried 4× with backoff), `formatLoopError` now spawns a 1.5s
`/user/balance` probe and renders one of three messages: no probe
(generic outage notice), reachable (main API up but /chat dying),
unreachable (DS or your network is down). All three say "this is
a DeepSeek-side problem, not Reasonix" and link
https://status.deepseek.com. Removes the misleading file header in
`loop/errors.ts` that claimed retry.ts swallowed all 5xx — it
doesn't, and never did. (#440)
- feat(wizard): first-launch language picker + full i18n. New
`language` step before `apiKey`, cursor defaults to
`detectSystemLanguage()` marked `(detected)`. Selection saves
immediately so all later wizard screens render in the chosen
language. Re-running `reasonix setup` opens at the same step with
the cursor on the saved language so Enter is a no-op. The wizard's
~30 hardcoded strings (welcome, prompts, validation errors, MCP
catalog hints, review labels, save errors, saved screen) all moved
to a new `wizard.*` namespace with zh-CN. (#442)
- feat(dashboard): picker modal protocol for web parity. New
`picker` modal kind drives sessions, checkpoint, and MCP
marketplace pickers from the same protocol. Closes the gap where
TUI-only modals stayed inaccessible from `/dashboard`. (#417,
#418, #419, #420)
- feat(dashboard): viewer modal kind for `/replay`. Loads an archived
plan into a read-only time-travel snapshot, mirrors the TUI replay
experience. (#421)
- feat(dashboard): cockpit tile + budget gauge + 14-day cost trend.
At-a-glance current-session telemetry on the overview panel. (#431)
- feat(dashboard): editable model picker in settings + `/pro`
one-shot panel + loop control (start / stop / countdown). The
settings tab is now the single place to flip model preset, arm
`/pro` for the next turn, or start an autonomous loop without
switching to the TUI. (#430, #432, #433)
- feat(dashboard): server surface for `/pro` / `/budget` / `/model`
/ `/loop`. POST endpoints under `/api/cockpit/*` carry the
mutations the panels above need. (#429)
**i18n sweep:**
- i18n(loop/errors): localize DeepSeek error messages — context
overflow, 401/402/422/400, 5xx (with the new reachability probe
variants), reason prefixes for budget/aborted/context-guard/stuck.
20 keys + zh-CN. (#444)
- i18n(loop): 14 user-facing yields in `step()` — budget exhausted /
80% warning, /pro armed, aborted-at-iter, tool-budget warning,
preflight fold/no-fold, flash + auto escalation, storm-broken,
history compaction (regular + aggressive), forcing-summary. The
`loop.*` namespace had 7 dead keys defined but never wired —
removed and replaced with 20 that match the actual yield shapes.
(#445)
- i18n(hooks/summary): hook outcome formatter (`hook PreToolUse/Bash
\`cmd\` block (output truncated at 256KB)`) and the force-summary
status / hallucinated-fallback / failed-fallback strings now go
through `t()`. New `hooks.*` and `summary.*` namespaces. (#446)
- i18n(app): ~26 hardcoded strings in `App.tsx` plus seven existing
`ui.*` keys that had been declared but never called (same dead-key
pattern as `loop.*`). New `app.*` namespace covers walk modal,
edit-mode cycle (review/auto/yolo), edit gate, dashboard stopped,
hash-memory note, bash-mode failures, hook header rows,
@mentions / @url, shell confirm, checkpoint saved, plan
continue / stop / revise. (#447)
- i18n(slash): four lagging slash handlers — `web-search-engine.ts`
was 0% localized, plus `mcp.ts` / `plans.ts` / `semantic.ts` had
small gaps. ~22 new keys. (#448)
- i18n(dashboard): translate the plan `idle` status pill — the
`active` / `done` pills already used `t()` but the third branch
was hardcoded English. (#443)
**Bug fixes:**
- fix(search): honor abort during recursive fs scans — Esc during a
large `search` tool call now exits promptly instead of finishing
the walk. (#400)
- fix(ui): refresh model badge on dashboard preset change and /pro
turns — the header pill stayed stale across server-side
preset/pro switches. (#403)
- fix(permissions): match Windows project keys case-insensitively —
the project allowlist hashed `C:\Foo` differently from `c:\foo`,
causing entries to "disappear" depending on which case the cwd
carried. (#402)
- fix(prompt): inline short single-line pastes verbatim — the long-
paste collapser was firing on tiny one-liners and burying them
behind a "(N chars pasted)" placeholder. (#397)
**Tests / refactor / docs:**
- test(mcp): cover startup summary states (#396)
- chore: improve loop.ts tests (#271)
- refactor(ui): quiet chat-screen chrome — fewer always-on rows on
the welcome card so the prompt stays close to the top. (#411)
- docs(readme): canonical install + subcommand cheatsheet (#408)
- docs(issues): split off display/rendering template, collect
terminal host info inline. (#412)
- docs(dashboard): PARITY.md audit — bucket E of #369. (#439)
## [0.30.5] — 2026-05-07
**Headline:** three contributor-led follow-ups from the #350 RFC plus
the #366 onboarding piece. The repeat-loop storm guard now exempts
obviously-safe inspector tools (`read_file`, `list_directory`,
`job_output`, `list_jobs`) so a model intentionally re-reading state
isn't flagged as stuck. A new `wait_for_job(jobId, timeoutMs?)` tool
replaces N-iteration polling loops with a single blocking call —
returns the moment the job exits or emits new output. And `/skill
new <name>` finally provides the missing creation entry-point for
user skills, scaffolding a stub with the right frontmatter so
first-time users don't have to read the source to author a skill.
**Features:**
- feat(storm): add `stormExempt` flag on `ToolDefinition`, set on
`read_file`, `list_directory`, `job_output`, `list_jobs`. Cheap
state-inspection no longer trips the repeat-loop guard. Mutating
tools and unknown tools still go through the existing window-and-
threshold check. (#350, PR #388 by @ctharvey)
- feat(jobs): new `wait_for_job(jobId, timeoutMs?)` shell tool —
blocks until the job exits or emits new output, bounded by
`timeoutMs` (default 5000, clamped to 0..30000). Returns
`{ exited, exitCode, latestOutput }`; `latestOutput` is the
delta since the call started, not the full buffer. Rides the
existing job registry's exit + output events; one call replaces
N polling iterations and is token-cheaper than the prior
re-call-job_output loop. (#350, PR #390 by @ctharvey)
- feat(skills): `/skill new <name>` scaffolds a stub at
`<project>/.reasonix/skills/<name>.md` with minimal frontmatter
+ a comment block listing the optional knobs (`runAs`,
`allowed-tools`, `model`). `/skill new <name> --global` writes
under `~/.reasonix/skills/` for cross-project use; auto-falls-
back to global when there's no project root. The empty
`/skill list` now ends with an explicit "no remote registry yet
— scaffold one with `/skill new <name>`" line so users don't
hunt for a marketplace that doesn't exist. (#366, PR #394)
**Bug fixes:**
- fix(skills): atomic create with `wx` flag — close the TOCTOU
race between `existsSync(...)` and `writeFileSync(...)` that
CodeQL flagged. The existence check IS the atomic write now;
`EEXIST` from a parallel writer surfaces as the same "skill
already exists" error instead of silently overwriting. (PR #394)
## [0.30.4] — 2026-05-07
**Headline:** sweep of the user-reported bug + onboarding queue from
the 0.30.2 / 0.30.3 launch day. Resume now restores the full session
state (cache hit %, cost, last context bar — previously they all
showed zero on a fresh boot until the first turn landed). The model
pill on assistant cards reflects the model that actually answered
after `/model` or `/preset` switches it. Bare `/model` opens an
interactive picker — typed-id entry stays for power users.
PowerShell users get Shift+Tab back via three additional encodings
(modifier-encoded back-tab, modifyOtherKeys, Kitty keyboard). And a
class of "junk text after exit" on Linux/fish (terminal-feature
replies leaking into the parent shell) gets a defensive stdin drain
in the exit path.
`--dir` is now discoverable for beginners — surfaced in the welcome
banner, the `/status` panel, the filesystem sandbox-escape error,
and a Getting Started callout in both READMEs.
**Bug fixes:**
- fix(stats): persist cache totals + `lastPromptTokens` across
resume. `SessionMeta` only carried `totalCostUsd` / `turnCount`,
so on every resume `/status` showed 0 context + 0% cache hit until
the first turn actually fired (even though the prefix was already
cached, costing $0.01 per turn). Three new fields are persisted
per-turn and seeded into `SessionStats` on resume; the existing
carryover plumbing now covers cache + last context.
(#364, PR #384)
- fix(ui): `/model <id>` and `/preset {auto,flash,pro}` now update
the active model in the agent store so the next assistant card
pill reflects the new selection. Previously `state.session.model`
was set once in `initialState()` and never mutated, so the pill
showed the launch-time model regardless of what actually answered
the turn. New `session.model.change` event; cards already opened
keep their captured model so mid-turn auto-escalation doesn't
retroactively relabel. (#372, PR #385)
- fix(input): recognize three additional Shift+Tab encodings for
PowerShell hosts and modern terminals — `\x1b[1;2Z` (modifier-
encoded back-tab some PowerShell hosts emit), `\x1b[27;2;9~`
(modifyOtherKeys level 2, which we already enable on startup),
`\x1b[9;2u` (Kitty keyboard envelope). Without these the edit-
mode cycle was silently dropped on PowerShell.
`/mode` typed fallback continues to work. (#373, PR #386)
- fix(tty): drain pending feature-detection replies on exit. Linux
reporters saw `^[]11;rgb:...^[\^[[33;1R^[[?62;1;4c` printed by
fish / bash after exiting reasonix — those bytes are responses to
OSC 11 / CPR / DA1 queries the runtime emits during startup that
sit in stdin's queue until exit. New `drainTtyResponses(50ms)`
reads-and-discards anything queued before control returns to the
parent shell. Layered on top of 0.30.3's alt-screen mitigation
(`--no-alt-screen` users get the fix too). (#365, PR #391)
**Features:**
- feat(ui): bare `/model` opens an interactive model picker — arrow-
key list, current model marked, `[r]` refreshes the catalog, esc
cancels. Seeds from the live DeepSeek catalog
(`useSessionInfo.listModels()`); falls back to the four known
DeepSeek ids when the catalog hasn't loaded yet so the picker
isn't empty on first open. The current id is always included even
when the API didn't return it. `/model <id>` typed entry stays
for power users. (#371, PR #387)
- feat(ui): surface `--dir` / pinned workspace for first-time users.
WelcomeBanner shows the workspace + relaunch hint in code mode;
`/status` adds a `workspace <path> · pinned at launch` line; the
filesystem sandbox-escape error points at `reasonix code --dir
<path>` instead of just dropping a raw error; both READMEs gain a
Getting Started subsection on `--dir`. No new slash command —
mid-session retargeting is intentionally not supported (the
message log + memory paths get tangled with stale roots).
(#370, PR #389)
## [0.30.3] — 2026-05-07
**Headline:** the chat scroll rewrite lands. Ink 5.2 → 7.0.2 / React
18.3 → 19.2, the cell-diff renderer is retired, and `reasonix code` /
`reasonix chat` default to alt-screen with row-precision virtual
scroll. PgUp / PgDn / mouse wheel scroll history; an empty prompt + ↑
also scrolls (Ctrl+P / Ctrl+N still recalls prompt history). When
scrolled away from bottom, the prompt hides and a `📖 reading
history — End / PgDn to return` hint appears. Resize-ghost dividers
and `<Static>`-related scroll-yank artifacts are gone with the
renderer that produced them. `--no-alt-screen` keeps the legacy
in-shell-scrollback behavior.
`web_search` gains a configurable backend — Mojeek stays the default,
but `/web-search-engine searxng <url>` switches to a self-hosted
SearXNG instance for users whose network blocks Mojeek. And the MCP
filesystem sandbox now fails with an actionable
`mkdir -p '<path>'` hint instead of a raw Node stack when the
configured directory doesn't exist; the wizard offers to create it
inline at config time.
**Features:**
- feat(ui): row-precision virtual scroll on Ink 7 + React 19.
`<Static>` retired (incompatible with alt-screen reflow);
`React.memo(CardRenderer)` plus reference-stable cards in the
reducer skip the reconciler on unchanged history. `useChatScroll`
drives an outer `overflow=hidden` clip + inner `marginTop=-N`
slide; `useBoxMetrics` reports inner / outer heights so bounds
clamp and auto-pin to bottom on new content. `App` owns
PgUp/PgDn/End/wheel; PromptInput hands off ↑/↓ on empty buffer
when pinned + idle. Ticker migrated to Ink 7's shared
`useAnimation`. (PR #380)
- feat(web): configurable `web_search` backend with SearXNG support.
`/web-search-engine` shows / switches the active engine; URL is
persisted to `~/.reasonix/config.json`. Mojeek remains the default;
the original Mojeek path is preserved as `searchMojeek()`. Protocol
auto-normalizes (`localhost:8080` → `http://...`); an unreachable
SearXNG endpoint surfaces an install hint instead of a raw fetch
error. (PR #338)
**Bug fixes:**
- fix(mcp): preflight the filesystem sandbox directory before
spawning `@modelcontextprotocol/server-filesystem`. Missing
directories now throw `MCP filesystem sandbox '<path>' does not
exist — create it with: mkdir -p '<path>'` instead of a raw Node
stack from inside `npx`'s child. The init wizard adds an inline
`[Y] create it (mkdir -p) / [N] enter a different path` confirm
step when the user types a path that doesn't exist, so bad config
never reaches disk. Spawn-time path deliberately does not
auto-mkdir — by then the user may not remember writing the
config. (#362, PR #379)
- fix(readme): website URLs corrected from `/reasonix/` to
`/DeepSeek-Reasonix/`. (PR #375)
**Chores:**
- chore(issue-template): bug template now asks for shell + terminal,
and the model-id examples track the current DeepSeek model
lineup. (PR #378)
## [0.30.2] — 2026-05-07
**Headline:** five user-visible polish items from the @dacec354 triage
batch. The streaming reply now carries a live `42 t/s` throughput pill
(plus a `1.2k tok · 42 t/s` summary on settled), and `ctrl-o` toggles a
full-tail view so a long plan / todo can be read while it's still
being written. The auto-mode undo banner gains a `space`-to-pause
keybind for users who want a beat to think before the 5-second window
expires. SessionPicker and the dashboard's session-cost displays both
respect the user's wallet currency now — USD wallets see `$0.05`, CNY
wallets see `¥0.36` end-to-end. And a long-standing scrollback bug
that left the "reasoning…" spinner spinning forever after reasoning
ended is fixed.
**Features:**
- feat(ui): live `42 t/s` pill on the streaming reply card; settled
card shows `1.2k tok · 42 t/s` summary. Computed via the bundled
DeepSeek tokenizer; gated below 4 tokens / 500 ms so the first
chunk doesn't print bogus rates. Re-renders ride the slow tick so
the rate keeps updating during chunk silence. (#334, PR #356)
- feat(ui): `space` toggles pause / resume on the auto-mode 5-second
undo countdown. While paused the bar freezes at the captured
fraction, the badge swaps to `Ns · paused`, and pressing `space`
again resumes from where it stopped. The `u` and `space` keybinds
share the same modal-and-prompt-empty gating. (#337, PR #356)
- feat(ui): `ctrl-o` toggles "expanded" mode on the live streaming
card. Expanded shows up to 60 visual lines (capped so the card
can't swallow the whole viewport) plus a `⋯ N earlier lines above`
hint when content overflows. Auto-resets to collapsed at turn end.
A `expanded ⌃o` / `preview ⌃o` pill in the card header advertises
the keybind. (#335, #337, PR #359)
**Bug fixes:**
- fix(ui): `splitCardStream` only treated the LAST card as live,
committing every earlier card to Ink's `<Static>`. When the model
streamed reasoning then content (or kicked off a tool card), the
reasoning card was no longer last — it got frozen into `<Static>`
while still `streaming: true`. `<Static>` doesn't re-render frozen
items, so when `reasoning.end` later set `streaming: false`, the
spinner kept spinning forever. The split now scans for the first
unsettled card and keeps everything from that index onward live;
a card only commits to `<Static>` once it's settled AND every
earlier card is too. (PR #358)
- fix(ui): SessionPicker hardcoded `¥` and ran USD → CNY itself, so
USD-wallet users saw `¥X.XX` in the session list. `SessionMeta`
gains `balanceCurrency`; App.tsx writes the live wallet currency
alongside `totalCostUsd` on each turn save. Picker accepts a
`walletCurrency` prop and falls back to each row's stored
currency. Cost rendering routes through the shared `formatCost()`
helper. (#312, PR #357)
- fix(dashboard): cost displays were hardcoded to `$` via `fmtUsd()`,
so a CNY-wallet user saw `session $0.5190` in the dashboard while
the same session read `¥0.024` in the CLI — both the symbol AND
the magnitude diverged because no conversion happened. Dashboard
now has its own `fmtCost(usd, currency)` mirroring the CLI's
conversion (CNY × 7.2). Overview current-session cost, cost-trend
day average, and the chat panel rail / status-bar costs all
thread the wallet currency from the cockpit balance. Claude-
equivalent comparisons in `usage.ts` stay USD by design — Claude's
API is USD-priced regardless of the user's wallet. (PR #360)
## [0.30.1] — 2026-05-07
**Headline:** two TUI ghost-rendering fixes for issues that only showed
up on the published binary. The CLI bundle now uses real Ink in
production instead of the cell-diff renderer that source mode never
exercised, eliminating a whole class of bugs invisible to `npx tsx`
repros. The `submit_plan` approval picker no longer leaves a
duplicated row behind when arrow-navigating choices — the live tool
card above the modal is suppressed while the picker owns the screen.
**Bug fixes:**
- fix(renderer): drop the `tsup` `ink → ink-compat` alias and the
`noExternal` for `ink` / `ink-text-input`. The CLI bundle keeps
`from "ink"` external; `ink` and `ink-text-input` move to runtime
`dependencies` so npm install pulls the real package. The
cell-diff renderer is no longer on the user-facing path; it's
retained only for direct test imports. Same behavior as `npx tsx
src/cli/index.ts` mode — TUI bug repros from source mode are now
valid for the published binary again. (#346, PR #354)
- fix(ui): `CardStream` accepts a `suppressLive` flag; `App.tsx`
computes a `modalOpen` flag from the union of pending modal states
and passes it through. While any picker / confirm modal owns the
screen, the unsettled live tool card above it stops repainting,
removing the rerender competition that left stale rows during
arrow-key navigation. (#352, PR #353 — thanks @ctharvey)
## [0.30.0] — 2026-05-06
**Headline:** slash commands grow first-class aliases, and the
cell-diff renderer hardens column targeting against per-cell width
miscounts. `/quit` and `/q` now resolve to `/exit` from a single
declaration on the spec instead of ad-hoc handler mirrors; `/?` →
`/help`, `/reset` → `/new`, `/lang` → `/language` follow the same
path. The renderer's `moveTo()` now uses CHA absolute (`\x1b[N+1G`)
for column targeting instead of CUF relative (`\x1b[NC`), making the
diff stream immune to the cursor-drift class of bug Anthropic
documented in `claude-code#14208`.
**Features:**
- feat(slash): `aliases?: readonly string[]` on `SlashCommandSpec`.
Adding a new alias is now a one-line edit to the canonical command
— dispatch, autocomplete, arg-context resolution, and the
dashboard `/api/slash` response all route through one
`resolveSlashAlias()` map built from `SLASH_COMMANDS` at module
init. Suggestion rows display aliases dimly (` · /quit /q`) so
they stay discoverable without doubling the autocomplete list.
Removes the per-handler alias mirrors that used to live in
`handlers/basic.ts` and `handlers/language.ts`. (#332, PR #347)
**Bug fixes:**
- fix(renderer): switch the X-axis branch of `moveTo()` from CUF
relative (`\x1b[NC`) to CHA absolute (`\x1b[N+1G`). Y-axis stays
on CUU/CUD since we don't track absolute terminal rows. Relative
column moves accumulate drift across frames whenever an earlier
write miscounts cell width — `▸` (U+25B8) rendered 2-cell on
fonts with East Asian fallback, ambiguous-width chars on
terminals that font-detect width, OSC8 hyperlinks parsed as
visible chars, etc. The next CUF lands at the wrong column,
ghost rows leak into adjacent hint lines, and the modal "shifts"
as users navigate. CHA targets the absolute column regardless of
what the terminal thinks — immune to the desync chain. Same fix
Anthropic shipped in claude-code per their issue #14208
post-mortem. (#346, PR #348)
## [0.29.1] — 2026-05-06
**Headline:** four user-reported bugs from the 0.29.0 release window.
The markdown renderer no longer turns English abbreviations like
`e.g.` into broken hyperlinks (which on cmd.exe / non-OSC-8 terminals
showed up as visible `]8;;file://e.g…` garbage and on the cell-diff
side desynced the renderer's prev-frame model). The cell-diff
renderer now defensively trail-clears any row whose content shrank
between frames. Resumed sessions keep their cumulative session cost
instead of resetting to `$0`. The Approve plan modal now shows the
plan body inline when the model didn't supply structured steps.
Wide markdown tables fall back to row-grouped key/value lines
instead of the previous column-grouped output.
**Bug fixes:**
- fix(markdown): stop linkifying English abbreviations + drop OSC 8
escape emission. The `FILE_REF_RE` extension class was too loose
(`{1,6}`), so `e.g`, `i.e`, `a.m` matched as file paths; `osc8()`
baked OSC 8 escape bytes into Text content, which the cell-diff
renderer's wrapLine stripped of zero-width chars but kept the
printable body — producing visible `]8;;file://e.ge.g]8;;` garbage
on every terminal. Tightened the regex (now requires path-shape,
line-number suffix, or extension >= 2 chars) and removed the OSC 8
escape — file refs still stand out via color + underline. (#330,
PR #341)
- fix(renderer): trail-clear rows that shrank between frames in the
cell-diff diff. The diff skipped cells where prev and next were
byte-equal (including trailing EMPTY cells), so any earlier ANSI
desync left stale chars in shrunken rows — manifested as the
shell-confirm modal showing `allow always` + `mand, ask again next
time` after Up/Down navigation. New `clearToEOL` patch type and a
per-row sweep after `diffEach`. (#330, PR #341)
- fix(stats): carry session cost / turn count across resume. The
TUI's `$X session` figure reset to `$0` on every resume even
though the disk meta still held the cumulative `totalCostUsd`.
`SessionStats` gains `seedCarryover()`; `CacheFirstLoop` reads the
meta on resume and seeds the carryover when prior messages exist.
(#333, PR #342)
- fix(plan): show the plan body in the Approve plan modal. When the
model called `submit_plan` with a markdown body but no structured
`steps`, the modal showed only the choice list — users had no way
to see what they were approving without scrolling back. The modal
now renders the body via `MarkdownView`, capped at 24 lines with
an overflow hint. (#336, PR #343)
- fix(markdown): row-group the table fallback layout. When a table
was too wide for the viewport, the fallback flattened it as N
"Component:" lines, then N "What:" lines, then N "Manual TCs:"
lines — the reader couldn't tell which value belonged to which
row. Swapped to row-first iteration with a blank separator
between rows. (#340, PR #344)
## [0.29.0] — 2026-05-06
**Headline:** tool dispatch is no longer strictly serial. When the model
+82 -3
View File
@@ -7,7 +7,7 @@
&nbsp;·&nbsp;
<a href="./README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/reasonix/">Website</a>
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<a href="./docs/ARCHITECTURE.md">Architecture</a>
&nbsp;·&nbsp;
@@ -41,6 +41,52 @@
> [!TIP]
> **Cache stability isn't a feature you turn on; it's an invariant the loop is designed around.** That's the whole reason Reasonix is DeepSeek-only — every layer is tuned to the byte-stable prefix-cache mechanic.
> [!NOTE]
> **Real user, single day (2026-05-01):** 435M input tokens, **99.82% cache hit**, ~$12 instead of the ~$61 the same workload would cost with no cache on `v4-flash` — see the [case study](./benchmarks/real-world-cache/README.md). DeepSeek provides the cacheable bytes; the four mechanisms in [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) are how Reasonix keeps them cacheable across long sessions.
<br/>
## Web search
Reasonix includes `web_search` and `web_fetch` tools. By default it uses **Mojeek** (no setup required). You can switch to a **self-hosted SearXNG** instance — a metasearch engine that aggregates whatever upstream engines your instance is configured for.
### Switching engines (persists to disk)
The `/search-engine` slash command (alias `/se`) writes your choice to `~/.reasonix/config.json` immediately — it survives restarts:
```
/search-engine mojeek # default, no external deps
/search-engine searxng # SearXNG at http://localhost:8080
/search-engine searxng http://192.168.1.100:8888 # custom endpoint
```
Equivalent `~/.reasonix/config.json`:
```json
{
"webSearchEngine": "searxng",
"webSearchEndpoint": "http://localhost:8080"
}
```
The tool picks up the change on the next call — no restart needed.
### Starting SearXNG
```sh
podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng
# or: docker run -d -p 8080:8080 searxng/searxng
```
Verify it's running:
```sh
curl http://localhost:8080/search?q=test
# → HTML search results page
```
> **Note:** The endpoint must include the protocol (`http://`). `localhost:8080` alone will fail — the tool will show a clear error telling you to install SearXNG if the server is unreachable.
<br/>
## Install
@@ -52,6 +98,37 @@ npx reasonix code # paste a DeepSeek API key on first run; persists after
Requires Node ≥ 22. Tested on macOS · Linux · Windows (PowerShell · Git Bash · Windows Terminal). Get a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
`npx` is the recommended path — no global install, always picks up the latest version. If you'll use Reasonix daily and want `reasonix` on your `PATH`, run `reasonix update` once and it'll do the `npm install -g` for you.
### Subcommand cheatsheet
| Command | When to use |
|---|---|
| `reasonix code [dir]` | Coding agent rooted at a project. **Start here.** |
| `reasonix chat` | Plain chat — no filesystem tools, just a conversation with persisted history. |
| `reasonix run "task"` | One-shot, streams the answer to stdout. Good for shell pipes. |
| `reasonix doctor` | Environment health check (Node version, API key, MCP wiring). |
| `reasonix update` | Upgrade Reasonix itself. |
Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`) are listed in `reasonix --help` and on the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
**Working in a different folder:** Reasonix scopes filesystem tools to the launch directory. To work elsewhere, pass `--dir`:
```bash
npx reasonix code --dir /path/to/project # or use a relative path
```
Mid-session switching isn't supported by design (the message log + memory paths get tangled with stale roots). Quit and relaunch with a new `--dir` to retarget. `/status` always shows the current pinned workspace.
**Author your first skill:** Skills are markdown playbooks the model can invoke (`/skill <name>`). There's no remote registry yet — you author them directly:
```bash
/skill new my-skill # scaffolds <project>/.reasonix/skills/my-skill.md
/skill new my-skill --global # or under ~/.reasonix/skills for cross-project use
```
Edit the file (`description:` frontmatter + body), then `/skill list` to see it. Add `runAs: subagent` to the frontmatter to spawn an isolated subagent loop instead of inlining the body.
<br/>
## What makes Reasonix different
@@ -87,8 +164,10 @@ Click any card to read the full architecture writeup → [Pillar 1](./docs/ARCHI
| Cost profile | **low per task** | premium | subscription + use | varies |
| DeepSeek prefix-cache | **engineered** | not applicable | not applicable | incidental |
| Embedded web dashboard | yes | — | n/a (IDE) | — |
| Configurable web search engine | `/search-engine` | — | — | — |
| Persistent per-workspace sessions | yes | partial | n/a | — |
| Plan mode · MCP · hooks · skills | yes | yes | yes | partial |
| Web search (Mojeek + SearXNG) | yes | yes | yes | yes |
| Open community development | yes | — | — | yes |
For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmarks/) — the numbers move with model pricing, so they live with the harness, not in the README.
@@ -99,7 +178,7 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
- [**Architecture**](./docs/ARCHITECTURE.md) — the four pillars, cache-first loop, harvesting, scaffolds
- [**Benchmarks**](./benchmarks/) — τ-bench-lite harness, transcripts, cost methodology
- [**Website**](https://esengine.github.io/reasonix/) — getting started, dashboard mockup, TUI mockup
- [**Website**](https://esengine.github.io/DeepSeek-Reasonix/) — getting started, dashboard mockup, TUI mockup
- [**Contributing**](./CONTRIBUTING.md) — comment policy, error-handling rules, library-over-hand-rolled
- [**Code of Conduct**](./CODE_OF_CONDUCT.md) · [**Security policy**](./SECURITY.md)
@@ -115,7 +194,7 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
**Open Discussions — opinions wanted:**
- [#20 · CLI / TUI design](https://github.com/esengine/reasonix/discussions/20) — what's broken, what's missing, what would you change?
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/reasonix/design/agent-dashboard.html)
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)
- [#22 · Future feature wishlist](https://github.com/esengine/reasonix/discussions/22) — what would you build into Reasonix next?
**Already using Reasonix and willing to help others discover it?** Publish blog posts, articles, screenshots, talks, or videos to [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell). The project has no marketing budget — community word of mouth is how new users find it. Sustained advocates earn the badge below, displayed next to the contributors wall once awarded:
+34 -3
View File
@@ -7,7 +7,7 @@
&nbsp;·&nbsp;
<strong>简体中文</strong>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/reasonix/">官方网站</a>
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<a href="./docs/ARCHITECTURE.md">架构文档</a>
&nbsp;·&nbsp;
@@ -52,6 +52,37 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
要求 Node ≥ 22。已在 macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)测过。[去拿 DeepSeek API Key →](https://platform.deepseek.com/api_keys) · 完整 flag 看 `reasonix code --help`。
`npx` 是推荐路径 —— 不用全局安装,每次都拿到最新版本。如果你天天用、想把 `reasonix` 装到 `PATH` 上,跑一次 `reasonix update` 就行,它会替你跑 `npm install -g`。
### 子命令速查
| 命令 | 适用场景 |
|---|---|
| `reasonix code [dir]` | 锁在某个项目根目录的编码 agent。**先用这个。** |
| `reasonix chat` | 纯聊天 —— 不挂文件系统工具,只是带历史的对话。 |
| `reasonix run "task"` | 一次性,把答案直接流到 stdout。适合 shell 管道。 |
| `reasonix doctor` | 环境体检(Node 版本、API Key、MCP 接线)。 |
| `reasonix update` | 升级 Reasonix 本身。 |
其他子命令(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)见 `reasonix --help` 和 [CLI 参考](https://esengine.github.io/DeepSeek-Reasonix/#cli)。
**在其他目录工作:** Reasonix 把文件系统工具作用域绑定在启动目录。要在别的目录工作,传 `--dir`:
```bash
npx reasonix code --dir /path/to/project # 也可以用相对路径
```
中途切换工作区是有意不支持的(消息日志和 memory 路径会和旧的根目录混在一起,状态错乱)。退出后用新的 `--dir` 重新启动来切换。`/status` 始终显示当前锁定的工作区。
**写第一个 Skill:** Skills 是模型可以调用的 markdown 剧本(`/skill <name>`)。暂无在线市场 —— 自己写:
```bash
/skill new my-skill # 在 <project>/.reasonix/skills/my-skill.md 生成模板
/skill new my-skill --global # 或者放到 ~/.reasonix/skills,跨项目共用
```
编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 里加 `runAs: subagent` 会以独立 subagent 跑,而不是把正文内联进父 prompt。
<br/>
## Reasonix 的不同之处
@@ -99,7 +130,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
- [**架构**](./docs/ARCHITECTURE.md) —— 四大支柱、缓存优先循环、思维提取、脚手架
- [**基准测试**](./benchmarks/) —— τ-bench-lite harness、transcript、成本方法论
- [**官方网站**](https://esengine.github.io/reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
- [**官方网站**](https://esengine.github.io/DeepSeek-Reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
- [**贡献指南**](./CONTRIBUTING.md) —— 注释规则、错误处理、用现成库不手写
- [**行为准则**](./CODE_OF_CONDUCT.md) · [**安全策略**](./SECURITY.md)
@@ -115,7 +146,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
**正在征集意见的 Discussions:**
- [#20 · CLI / TUI 设计](https://github.com/esengine/reasonix/discussions/20) —— 哪里坏了、哪里少东西、哪里你会怎么改?
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/reasonix/design/agent-dashboard.html)拍砖
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)拍砖
- [#22 · 未来功能愿望单](https://github.com/esengine/reasonix/discussions/22) —— 你希望 Reasonix 长出什么功能?
**正在使用 Reasonix,愿意让更多人了解它?** 欢迎将相关博客、文章、截图、演讲或视频发布到 [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell)。项目没有营销预算,新用户主要通过社区口碑找到这里。持续参与传播的用户将获得下方这枚徽章,颁发后会展示在贡献者墙旁:
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+76
View File
@@ -0,0 +1,76 @@
# Real-world cache hit — single user, single day
A real Reasonix user shared their DeepSeek dashboard for **2026-05-01**.
Used with permission, anonymized.
![DeepSeek usage dashboard, 2026-05-01](2026-05-01-deepseek-dashboard.png)
## The numbers
| | Tokens |
|---|---:|
| Input — cache hit | 435,033,856 |
| Input — cache miss | 767,616 |
| Output | 179,763 |
| **Day total** | **435,981,235** |
**Cache hit ratio (input):**
`435,033,856 / (435,033,856 + 767,616)` = **99.82%**
## Cost — using the prices Reasonix bills against (`src/telemetry/stats.ts`)
USD per 1M tokens — `inputCacheHit / inputCacheMiss / output`:
- `deepseek-v4-flash` — `0.028 / 0.139 / 0.278`
- `deepseek-v4-pro` — `0.139 / 1.667 / 3.333`
Assuming **v4-flash** (the project default):
| | This user (99.82% hit) | Same workload, **0% cache** |
|---|---:|---:|
| Cache-hit input | $12.18 | — |
| Cache-miss input | $0.11 | $60.58 |
| Output | $0.05 | $0.05 |
| **Total / day** | **$12.34** | **$60.63** |
→ Cache saved this user **$48.29**, or **~80%** off the un-cached baseline, on a single day.
On **v4-pro** (5× the prefix-cache discount) the same workload would cost
**~$62.35** vs **~$727.08** without cache — a **~91% saving**.
## "Isn't that just DeepSeek's prefix cache?"
DeepSeek's API ships prefix caching enabled by default; the *cache* is theirs,
the *hit rate* is the client's. Same API, different clients, very different
hit rates:
- DeepSeek's own web chat: 60–80% within a single conversation, drops to 0%
on a new session (system prompt may differ).
- Cherry Studio / Open WebUI / generic OpenAI-shape SDKs: typically 30–60%
on long sessions — history gets reordered, tool specs get re-serialized,
every drift breaks the prefix.
- Cline / Continue and other XML-tool-call clients: lower still — every tool
result inlines into the conversation, shifting bytes the cache keys on.
99.82% is what falls out of these four design choices in Reasonix:
1. **`ImmutablePrefix`** (`src/memory.ts`) — system prompt + tool specs are
frozen at session start. Same byte sequence every turn.
2. **`AppendOnlyLog`** — turns only append. No reorder, no edit-in-place.
3. **`VolatileScratch`** — chain-of-thought / per-turn scratch lives outside
the cached prefix so it never poisons the next hit.
4. **Auto-compact** — when context approaches the cap, older turns fold into
a summary message *appended* to the prefix; the prefix itself isn't
rewritten, so the cache survives the fold.
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
the bytes cacheable.
## Reproduce
The synthetic side of this lives in `benchmarks/tau-bench/` — same task set
run through `CacheFirstLoop` vs a deliberately cache-hostile baseline. The
real-world data above is what the synthetic numbers look like once a user
runs the harness in anger.
Submit your own dashboard screenshot if you want it anonymized and added
here — open an issue.
+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.
+152
View File
@@ -812,6 +812,9 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
}
.chip-f:hover { background: var(--bg-hover); color: var(--fg-1); }
.chip-f.active { color: var(--c-brand); border-color: var(--c-brand); background: rgba(121,192,255,.08); }
.chip-f.static { cursor: default; }
.chip-f.static:hover { background: var(--bg-elev); color: var(--fg-2); }
.chip-f.static.active:hover { color: var(--c-brand); background: rgba(121,192,255,.08); }
.chip-f .ct { color: var(--fg-4); font-size: 10px; }
.chip-f.active .ct { color: var(--c-brand); }
.chip-f .x { color: var(--fg-4); padding: 0 2px; }
@@ -2016,6 +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 "";
}
+18
View File
@@ -4,6 +4,24 @@ export function fmtUsd(n: number | null | undefined): string {
return `$${n.toFixed(n < 0.01 ? 6 : 4)}`;
}
/** Keep in sync with src/cli/ui/theme/tokens.ts USD_TO_CNY. */
const USD_TO_CNY = 7.2;
/** USD-internal cost rendered in the wallet's display currency. Undefined currency → CNY (matches CLI default). */
export function fmtCost(
usd: number | null | undefined,
currency: string | null | undefined,
fractionDigits?: number,
): string {
if (usd === null || usd === undefined) return "—";
const cur = currency ?? "CNY";
const amount = cur === "CNY" ? usd * USD_TO_CNY : usd;
if (amount === 0) return cur === "CNY" ? "¥0" : "$0";
const sym = cur === "CNY" ? "¥" : cur === "USD" ? "$" : `${cur} `;
const digits = fractionDigits ?? (Math.abs(amount) < 0.01 ? 6 : 4);
return `${sym}${amount.toFixed(digits)}`;
}
export function fmtPct(n: number | null | undefined): string {
if (n === null || n === undefined) return "—";
return `${(n * 100).toFixed(1)}%`;
+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`;
}
+14 -7
View File
@@ -6,15 +6,17 @@ import {
ChoiceModal,
EditReviewModal,
type OnResolve,
PickerModal,
PlanModal,
RevisionModal,
ShellModal,
ViewerModal,
WorkspaceModal,
parseToolArgs,
} from "../components/chat-internals.js";
import { MODE, TOKEN, api } from "../lib/api.js";
import { appBus, showToast } from "../lib/bus.js";
import { fmtUsd } from "../lib/format.js";
import { fmtCost, fmtUsd } from "../lib/format.js";
import { html } from "../lib/html.js";
import { t, useLang } from "../i18n/index.js";
@@ -559,7 +561,7 @@ export function ChatPanel() {
<div class="chat-shell">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">
<div class="chips" style="padding:0">
<span class="chip-f active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
<span class="chip-f static active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
</div>
<div class="header-pickers" style="margin-left:auto">
${
@@ -684,7 +686,11 @@ export function ChatPanel() {
? html`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />`
: modal.kind === "revision"
? html`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />`
: null
: modal.kind === "picker"
? html`<${PickerModal} modal=${modal} onResolve=${resolveModal} />`
: modal.kind === "viewer"
? html`<${ViewerModal} modal=${modal} onResolve=${resolveModal} />`
: null
: null
}
@@ -791,6 +797,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
const showBudget = stats != null && typeof budgetUsd === "number" && budgetUsd > 0;
const budgetPct = showBudget ? Math.min(120, (stats.totalCostUsd / budgetUsd) * 100) : 0;
const budgetTone = budgetPct >= 100 ? "err" : budgetPct >= 80 ? "warn" : "";
const walletCurrency = stats?.balance?.[0]?.currency;
return html`
<aside class="chat-rail">
${activePlan ? html`<${ActivePlanCard} plan=${activePlan} />` : null}
@@ -801,7 +808,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
<div class="rh">${t("chat.railSession")}</div>
<div class="rail-kv"><span class="k">${t("chat.railTurns")}</span><span class="v">${stats.turns.toLocaleString()}</span></div>
<div class="rail-kv"><span class="k">${t("chat.railPromptTok")}</span><span class="v">${stats.lastPromptTokens.toLocaleString()}</span></div>
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtUsd(stats.totalCostUsd)}</span></div>
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtCost(stats.totalCostUsd, walletCurrency)}</span></div>
<div class="progress-row" style="margin-top:8px">
<span class="lbl">${t("chat.railCacheHit")}</span>
<div class=${`progress ${cacheTone}`}><div class="progress-fill" style=${`width:${cachePct}%`}></div></div>
@@ -819,7 +826,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
<div class="progress-row">
<span class="lbl">${t("chat.railSpend")}</span>
<div class=${`progress ${budgetTone}`}><div class="progress-fill" style=${`width:${Math.min(100, budgetPct)}%`}></div></div>
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtUsd(stats.totalCostUsd)} / ${fmtUsd(budgetUsd)}</span>
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtCost(stats.totalCostUsd, walletCurrency)} / ${fmtCost(budgetUsd, walletCurrency)}</span>
</div>
</div>
`
@@ -989,11 +996,11 @@ function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
</span>
<span class="status-item">
<span class="status-label">${t("chat.statusTurn")}</span>
<code>${fmtUsd(stats.lastTurnCostUsd)}</code>
<code>${fmtCost(stats.lastTurnCostUsd, balance?.currency)}</code>
</span>
<span class="status-item">
<span class="status-label">${t("chat.statusSession")}</span>
<code>${fmtUsd(stats.totalCostUsd)}</code>
<code>${fmtCost(stats.totalCostUsd, balance?.currency)}</code>
<span class="muted" style="font-size: 10px;">
${t("chat.statusTurns", { count: stats.turns, s: stats.turns === 1 ? "" : "s" })}
</span>
+17 -4
View File
@@ -68,6 +68,7 @@ export function HooksPanel() {
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<string | null>(null);
const [eventFilter, setEventFilter] = useState<string>("all");
const load = useCallback(async () => {
try {
@@ -129,19 +130,31 @@ export function HooksPanel() {
data.events.length > 0
? data.events
: Array.from(new Set(matrixRows.flatMap((r) => Object.keys(r.cells))));
const visibleRows =
eventFilter === "all"
? matrixRows
: matrixRows.filter((r) => r.cells[eventFilter]?.on);
const gridCols = `220px repeat(${Math.max(events.length, 1)}, minmax(0, 1fr))`;
return html`
<div style="display:flex;flex-direction:column;gap:6px">
<div class="chips">
<span class="chip-f active">${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
${data.events.map((ev) => html`<span class="chip-f">${ev}</span>`)}
<span
class=${`chip-f ${eventFilter === "all" ? "active" : ""}`}
onClick=${() => setEventFilter("all")}
>${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
${data.events.map(
(ev) => html`<span
class=${`chip-f ${eventFilter === ev ? "active" : ""}`}
onClick=${() => setEventFilter(ev)}
>${ev}</span>`,
)}
</div>
${info ? html`<div><span class="pill ok">${info}</span></div>` : null}
${error ? html`<div class="card accent-err">${error}</div>` : null}
${sectionH3(t("hooks.eventMatrix"), t("hooks.matrixSub", { scripts: matrixRows.length, s: matrixRows.length === 1 ? "" : "s", events: events.length }))}${
matrixRows.length === 0
visibleRows.length === 0
? html`<div class="card" style="color:var(--fg-3)">
${t("hooks.noHooks")}
</div>`
@@ -152,7 +165,7 @@ export function HooksPanel() {
<div>${t("hooks.colScript")}</div>
${events.map((ev) => html`<div>${ev}</div>`)}
</div>
${matrixRows.map(
${visibleRows.map(
(r) => html`
<div class="row" style=${`grid-template-columns:${gridCols}`}>
<div class="cell" title=${r.command}>
+28 -3
View File
@@ -1,4 +1,5 @@
import { fmtCompactNum, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
import { budgetTone, deriveBudgetState } from "../lib/budget.js";
import { fmtCompactNum, fmtCost, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
import { html } from "../lib/html.js";
import { usePoll } from "../lib/use-poll.js";
import { t, useLang } from "../i18n/index.js";
@@ -65,6 +66,9 @@ interface OverviewData {
toolCount?: number;
cwd?: string;
cockpit?: CockpitData;
budgetUsd?: number | null;
/** Cumulative session spend in USD — set when a session is attached. */
sessionSpendUsd?: number | null;
}
function kpi(label: string, value: unknown, delta?: unknown, deltaTone?: "up" | "down" | "flat") {
@@ -107,6 +111,25 @@ function balanceKpi(c: CockpitData) {
return kpi(t("overview.balance"), `${symbol}${c.balance.total}`, c.balance.currency, "flat");
}
function budgetKpi(o: OverviewData) {
const state = deriveBudgetState(o.budgetUsd, o.cockpit?.currentSession?.totalCostUsd ?? null);
if (state.kind === "off") return null;
const tone = budgetTone(state);
const valueColor =
tone === "err"
? "color:var(--c-err)"
: tone === "warn"
? "color:var(--c-warn)"
: "";
return html`
<div class="kpi cock-w-1">
<div class="label">${t("overview.budget")}</div>
<div class="value" style=${valueColor}>${fmtUsd(state.spent)} / ${fmtUsd(state.cap)}</div>
<div class=${`progress ${tone}`} style="margin-top:4px"><div class="progress-fill" style=${`width:${Math.min(100, state.pct)}%`}></div></div>
</div>
`;
}
function tokens7dKpi(c: CockpitData) {
if (!c.tokens7d) return kpi(t("overview.tokens7d"), "—", t("overview.noUsageYet"), "flat");
const d = deltaPctText(c.tokens7d.deltaPct);
@@ -144,6 +167,7 @@ function currentSessionBlock(c: CockpitData) {
`;
}
const s = c.currentSession;
const currency = c.balance?.currency;
return html`
<div class="cock-list cock-w-2">
<div class="ch"><span class="ttl">${t("overview.currentSession")}</span></div>
@@ -153,7 +177,7 @@ function currentSessionBlock(c: CockpitData) {
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:8px;font-family:var(--font-mono);font-size:11px">
<div><span style="color:var(--fg-3)">${t("overview.promptTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.lastPromptTokens)}</div></div>
<div><span style="color:var(--fg-3)">${t("overview.completionTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.completionTokens)}</div></div>
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtUsd(s.totalCostUsd)}</div></div>
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtCost(s.totalCostUsd, currency)}</div></div>
</div>
</div>
`;
@@ -185,7 +209,7 @@ function costTrendSpark(c: CockpitData) {
return html`
<div class="chart cock-w-2">
<div class="chart-h"><span class="title">${t("overview.costTrend")}</span></div>
<div class="chart-v">${fmtUsd(avg)}<span class="unit">${t("overview.dayAvg")}</span></div>
<div class="chart-v">${fmtCost(avg, c.balance?.currency)}<span class="unit">${t("overview.dayAvg")}</span></div>
<div class="chart-spark">
<svg viewBox=${`0 0 ${w} ${h}`} preserveAspectRatio="none">
<polyline fill="none" stroke="var(--c-brand)" stroke-width="1.5" points=${points} />
@@ -284,6 +308,7 @@ export function OverviewPanel() {
${tokens7dKpi(c)}
${cacheHitKpi(c)}
${toolCallsKpi(c)}
${budgetKpi(o)}
${currentSessionBlock(c)}
${costTrendSpark(c)}
+2 -2
View File
@@ -122,8 +122,8 @@ export function PermissionsPanel() {
}
<div class="chips">
<span class="chip-f active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
<span class="chip-f">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
<span class="chip-f static active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
<span class="chip-f static">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
</div>
${
+22 -6
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() {
@@ -37,6 +37,7 @@ export function PlansPanel() {
const { data, error, loading } = usePoll<PlansData>("/plans", 8000);
const [openIdx, setOpenIdx] = useState<number | null>(null);
const [filter, setFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "done">("all");
if (loading && !data)
return html`<div class="card" style="color:var(--fg-3)">${t("plans.loading")}</div>`;
@@ -48,13 +49,19 @@ export function PlansPanel() {
${t("plans.noPlans")}
</div>`;
const statusFiltered =
statusFilter === "all"
? plans
: statusFilter === "active"
? plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1)
: plans.filter((p) => p.completionRatio >= 1);
const filtered = filter.trim()
? plans.filter(
? statusFiltered.filter(
(p) =>
p.session.toLowerCase().includes(filter.toLowerCase()) ||
(p.summary ?? "").toLowerCase().includes(filter.toLowerCase()),
)
: plans;
: statusFiltered;
const open = openIdx !== null ? plans[openIdx] : null;
@@ -71,12 +78,21 @@ export function PlansPanel() {
/>
</div>
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f active">${t("common.all")} <span class="ct">${plans.length}</span></span>
<span class="chip-f">
<span
class=${`chip-f ${statusFilter === "all" ? "active" : ""}`}
onClick=${() => setStatusFilter("all")}
>${t("common.all")} <span class="ct">${plans.length}</span></span>
<span
class=${`chip-f ${statusFilter === "active" ? "active" : ""}`}
onClick=${() => setStatusFilter("active")}
>
${t("plans.active")}
<span class="ct">${plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1).length}</span>
</span>
<span class="chip-f">
<span
class=${`chip-f ${statusFilter === "done" ? "active" : ""}`}
onClick=${() => setStatusFilter("done")}
>
${t("plans.done")} <span class="ct">${plans.filter((p) => p.completionRatio >= 1).length}</span>
</span>
</div>
+4 -4
View File
@@ -179,13 +179,13 @@ export function SemanticPanel() {
<div style="display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:14px;align-items:start">
<div style="display:flex;flex-direction:column;gap:10px;min-width:0">
<div class="chips">
<span class=${`chip-f ${idx?.exists ? "active" : ""}`}>
<span class=${`chip-f static ${idx?.exists ? "active" : ""}`}>
${idx?.exists ? t("semantic.indexBuilt") : t("semantic.noIndex")}
</span>
${
ready
? html`<span class="chip-f" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
: html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
? html`<span class="chip-f static" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
: html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
}
</div>
${info ? html`<div><span class="pill info">${info}</span></div>` : null}
@@ -730,7 +730,7 @@ function ChipFormRow({
<div style="display:flex;flex-wrap:wrap;gap:4px">
${value.map(
(e) => html`
<span class="chip-f">
<span class="chip-f static">
<span>${e}</span>
<span class="x" style="cursor:pointer" onClick=${() => remove(e)} title="remove">×</span>
</span>
+1 -1
View File
@@ -68,7 +68,7 @@ export function SessionsPanel() {
/>
</div>
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
<span class="chip-f static active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
</div>
<div class="ssl-rows">
${filtered.map(
+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"),
+21 -6
View File
@@ -29,6 +29,7 @@ export function SkillsPanel() {
const [newName, setNewName] = useState("");
const [newScope, setNewScope] = useState<"global" | "project">("global");
const [filter, setFilter] = useState("");
const [scopeFilter, setScopeFilter] = useState<"all" | Scope>("all");
const load = useCallback(async () => {
try {
@@ -120,13 +121,15 @@ export function SkillsPanel() {
...data.global.map((s) => ({ scope: "global" as Scope, ...s })),
...data.builtin.map((s) => ({ scope: "builtin" as Scope, ...s })),
];
const scopeFiltered =
scopeFilter === "all" ? allWith : allWith.filter((s) => s.scope === scopeFilter);
const filtered = filter.trim()
? allWith.filter(
? scopeFiltered.filter(
(s) =>
s.name.toLowerCase().includes(filter.toLowerCase()) ||
(s.description ?? "").toLowerCase().includes(filter.toLowerCase()),
)
: allWith;
: scopeFiltered;
return html`
<div class="sessions-grid">
@@ -141,10 +144,22 @@ export function SkillsPanel() {
/>
</div>
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f active">${t("common.all")} <span class="ct">${allWith.length}</span></span>
<span class="chip-f">${t("skills.project")} <span class="ct">${data.project.length}</span></span>
<span class="chip-f">${t("skills.global")} <span class="ct">${data.global.length}</span></span>
<span class="chip-f">${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "all" ? "active" : ""}`}
onClick=${() => setScopeFilter("all")}
>${t("common.all")} <span class="ct">${allWith.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "project" ? "active" : ""}`}
onClick=${() => setScopeFilter("project")}
>${t("skills.project")} <span class="ct">${data.project.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "global" ? "active" : ""}`}
onClick=${() => setScopeFilter("global")}
>${t("skills.global")} <span class="ct">${data.global.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "builtin" ? "active" : ""}`}
onClick=${() => setScopeFilter("builtin")}
>${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
</div>
<div style="padding:0 12px 8px;display:flex;gap:6px;flex-wrap:wrap">
+2 -2
View File
@@ -37,8 +37,8 @@ export function ToolsPanel() {
return html`
<div style="display:flex;flex-direction:column;gap:14px">
<div class="chips">
<span class="chip-f active">${t("common.all")} <span class="ct">${d.total}</span></span>
${d.planMode ? html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
<span class="chip-f static active">${t("common.all")} <span class="ct">${d.total}</span></span>
${d.planMode ? html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
</div>
${
+2 -2
View File
@@ -174,8 +174,8 @@ export function UsagePanel() {
return html`
<div style="display:flex;flex-direction:column;gap:6px">
<div class="chips">
<span class="chip-f active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
<span class="chip-f">${u.logSize}</span>
<span class="chip-f static active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
<span class="chip-f static">${u.logSize}</span>
</div>
${
+1 -1
View File
@@ -195,7 +195,7 @@ src/
│ ├── skills.ts # list + invoke SKILL.md playbooks
│ ├── subagent.ts # spawn_subagent — flash+high by default
│ ├── plan.ts # submit_plan (review gate)
│ └── web.ts # web_search, web_fetch
│ └── web.ts # web_search, web_fetch (multi-engine: Mojeek or SearXNG)
├── mcp/ # MCP client + bridge (stdio + SSE)
├── memory.ts # ImmutablePrefix / AppendOnlyLog / VolatileScratch
├── project-memory.ts # REASONIX.md loader
+1 -1
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>
+429 -450
View File
File diff suppressed because it is too large. Load diff
+6 -8
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.29.0",
"version": "0.31.0",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
@@ -72,12 +72,12 @@
"commander": "^12.1.0",
"eventsource-parser": "^3.0.0",
"ignore": "^7.0.5",
"ink": "^7.0.2",
"ink-text-input": "^6.0.0",
"node-html-parser": "^7.1.0",
"picomatch": "^4.0.4",
"react": "^18.3.1",
"react-reconciler": "^0.29.2",
"react": "^19.2.6",
"string-width": "^7.2.0",
"yoga-layout": "^3.2.1",
"zod": "^4.4.1"
},
"devDependencies": {
@@ -86,14 +86,12 @@
"@stryker-mutator/vitest-runner": "^9.6.1",
"@types/node": "^22.9.0",
"@types/picomatch": "^4.0.3",
"@types/react": "^18.3.12",
"@types/react-reconciler": "^0.28.9",
"@types/react": "^19.2.14",
"@vitest/coverage-v8": "^2.1.5",
"esbuild": "^0.21.5",
"highlight.js": "^11.10.0",
"htm": "^3.1.1",
"ink": "^5.1.0",
"ink-text-input": "^6.0.0",
"ink-testing-library": "^4.0.0",
"marked": "^15.0.12",
"preact": "^10.22.0",
"simple-git-hooks": "^2.13.1",
+37 -16
View File
@@ -1,10 +1,17 @@
import { render } from "ink";
import React, { useState } from "react";
import { loadApiKey, readConfig, searchEnabled } from "../../config.js";
import {
loadApiKey,
readConfig,
searchEnabled,
webSearchEndpoint,
webSearchEngine,
} from "../../config.js";
import { loadDotenv } from "../../env.js";
import type { CacheFirstLoop } from "../../loop.js";
import { McpClient } from "../../mcp/client.js";
import { type InspectionReport, inspectMcpServer } from "../../mcp/inspect.js";
import { preflightStdioSpec } from "../../mcp/preflight.js";
import { type McpClientHost, bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
@@ -24,6 +31,7 @@ import { registerWebTools } from "../../tools/web.js";
import { App } from "../ui/App.js";
import { SessionPicker } from "../ui/SessionPicker.js";
import { Setup } from "../ui/Setup.js";
import { drainTtyResponses } from "../ui/drain-tty.js";
import { KeystrokeProvider } from "../ui/keystroke-context.js";
import { formatMcpLifecycleEvent } from "../ui/mcp-lifecycle.js";
import { formatMcpSlowToast } from "../ui/mcp-toast.js";
@@ -101,6 +109,7 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
: ctx.getRequestedCount() === 1 && ctx.getMcpPrefix()
? (ctx.getMcpPrefix() as string)
: "";
if (spec.transport === "stdio") preflightStdioSpec(spec);
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
@@ -300,6 +309,13 @@ export interface ChatOptions {
* URL is visible in the status bar.
*/
noDashboard?: boolean;
/**
* Render into the terminal's alternate screen buffer. Default true —
* alt-screen avoids the scrollback-mode resize/wrap ghost class. Pass
* false (CLI: `--no-alt-screen`) when the chat output needs to remain
* in shell scrollback after exit.
*/
altScreen?: boolean;
}
interface RootProps extends ChatOptions {
@@ -436,7 +452,9 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
if (runtime.size() === 0 && !opts.seedTools) {
tools = undefined;
}
const mcpSpecs = runtime.specs();
// Preserve configured specs even when startup leaves them unbridged
// so `/mcp` can still surface the configured-but-offline set.
const mcpSpecs = [...requestedSpecs];
const mcpServers = runtime.summaries();
// Register web search/fetch tools unless explicitly disabled. DDG
@@ -444,7 +462,10 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
// a question needs info fresher than its training data.
if (searchEnabled()) {
if (!tools) tools = new ToolRegistry();
registerWebTools(tools);
registerWebTools(tools, {
webSearchEngine: webSearchEngine(),
webSearchEndpoint: webSearchEndpoint(),
});
}
// Memory tools — available in every session, not just code mode.
@@ -475,17 +496,6 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
const showPicker =
!opts.session && !opts.forceResume && listSessionsForWorkspace(launchWorkspace).length > 0;
// No startup clear, no resize listener. Earlier attempts wrote
// various combinations of \x1b[2J / \x1b[3J / cursor-home to
// present a 'clean canvas' on launch and to neutralize Ink's
// eraseLines miscount on resize, but on xterm.js-based terminals
// (VSCode integrated terminal in particular) those sequences
// interfere with scrollback in ways that make wheel-up scroll
// dead. Letting Ink mount directly leaves the user's previous
// shell prompt visible above (scrolling up just works) and
// accepts the resize-ghost / launch-noise tradeoffs as known
// limitations — `/clear` is the manual reset.
const { waitUntilExit } = render(
<Root
initialKey={initialKey}
@@ -498,12 +508,23 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
{...opts}
session={resolvedSession}
/>,
// patchConsole:false — winpty/MINTTY redraw-glitch source.
{ exitOnCtrlC: true, patchConsole: false },
{
exitOnCtrlC: true,
// patchConsole:false — winpty/MINTTY redraw-glitch source.
patchConsole: false,
incrementalRendering: true,
// Default true — alt-screen is the only mode without scrollback-
// reflow ghosting. `--no-alt-screen` opts back into scrollback mode
// for users who need chat output preserved in shell history on exit.
alternateScreen: opts.altScreen !== false,
},
);
try {
await waitUntilExit();
} finally {
await runtime.closeAll();
// Eat any pending terminal-feature-detection responses (#365) so the
// parent shell doesn't print them as junk after exit.
await drainTtyResponses();
}
}
+3
View File
@@ -66,6 +66,8 @@ export interface CodeOptions {
systemAppend?: string;
/** Path to a UTF-8 text file whose contents are appended to the code system prompt. */
systemAppendFile?: string;
/** Default true. Pass false (CLI: `--no-alt-screen`) to keep chat output in shell scrollback. */
altScreen?: boolean;
}
export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
@@ -190,5 +192,6 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
forceResume: opts.forceResume,
forceNew: opts.forceNew,
noDashboard: opts.noDashboard,
altScreen: opts.altScreen,
});
}
-1
View File
@@ -1,7 +1,6 @@
/** `reasonix mcp browse` — Ink TUI for the MCP marketplace. Lazy-loads pages on scroll. */
import { Box, Text, render, useApp, useInput } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { readConfig, writeConfig } from "../../config.js";
import { loadDotenv } from "../../env.js";
+2
View File
@@ -1,6 +1,7 @@
import { McpClient } from "../../mcp/client.js";
import { inspectMcpServer } from "../../mcp/inspect.js";
import type { InspectionReport } from "../../mcp/inspect.js";
import { preflightStdioSpec } from "../../mcp/preflight.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
@@ -15,6 +16,7 @@ export interface McpInspectOptions {
export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void> {
const spec = parseMcpSpec(opts.spec);
if (spec.transport === "stdio") preflightStdioSpec(spec);
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
+2
View File
@@ -11,6 +11,7 @@ import {
import { loadDotenv } from "../../env.js";
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
import { McpClient } from "../../mcp/client.js";
import { preflightStdioSpec } from "../../mcp/preflight.js";
import { bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
@@ -103,6 +104,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
: requestedSpecs.length === 1 && opts.mcpPrefix
? opts.mcpPrefix
: "";
if (spec.transport === "stdio") preflightStdioSpec(spec);
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
+4
View File
@@ -113,6 +113,7 @@ program
.option("--harvest", t("ui.harvestHint"))
.option("--budget <usd>", t("ui.budgetHint"), (v) => Number.parseFloat(v))
.option("--no-dashboard", t("ui.noDashboard"))
.option("--no-alt-screen", "keep chat output in shell scrollback (legacy mode, ghost-prone)")
.option("--system-append <prompt>", t("ui.systemAppendHint"))
.option("--system-append-file <path>", t("ui.systemAppendFileHint"))
.action(async (dir: string | undefined, opts) => {
@@ -128,6 +129,7 @@ program
noDashboard: opts.dashboard === false,
systemAppend: opts.systemAppend,
systemAppendFile: opts.systemAppendFile,
altScreen: opts.altScreen !== false,
});
});
@@ -155,6 +157,7 @@ program
.option("--mcp-prefix <str>", t("ui.mcpPrefixHint"))
.option("--no-config", t("ui.noConfigHint"))
.option("--no-dashboard", t("ui.noDashboard"))
.option("--no-alt-screen", "keep chat output in shell scrollback (legacy mode, ghost-prone)")
.action(async (opts) => {
const defaults = resolveDefaults({
model: opts.model,
@@ -190,6 +193,7 @@ program
forceResume: continueOpts.forceResume,
forceNew: !!opts.new,
noDashboard: opts.dashboard === false,
altScreen: opts.altScreen !== false,
});
});
+356 -117
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,9 +77,11 @@ import { formatSubagentResult, spawnSubagent } from "../../tools/subagent.js";
import { webFetch } from "../../tools/web.js";
import { openTranscriptFile } from "../../transcript/log.js";
import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
import { CheckpointPicker } from "./CheckpointPicker.js";
import { ChoiceConfirm, type ChoiceConfirmChoice } from "./ChoiceConfirm.js";
import { EditConfirm, type EditReviewChoice } from "./EditConfirm.js";
import { McpHub } from "./McpHub.js";
import { ModelPicker } from "./ModelPicker.js";
import { PlanCheckpointConfirm } from "./PlanCheckpointConfirm.js";
import { PlanConfirm, type PlanConfirmChoice } from "./PlanConfirm.js";
import { PlanRefineInput } from "./PlanRefineInput.js";
@@ -84,6 +94,8 @@ import { SlashArgPicker } from "./SlashArgPicker.js";
import { SlashSuggestions } from "./SlashSuggestions.js";
import { WelcomeBanner } from "./WelcomeBanner.js";
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
import type { PickerSnapshot, ViewerSnapshot } from "./dashboard/use-picker-broadcast.js";
import { useViewerBroadcast } from "./dashboard/use-picker-broadcast.js";
import { formatEditResults } from "./edit-history.js";
import { loopEventToDashboard } from "./effects/loop-to-dashboard.js";
import { appendGlobalMemory, appendProjectMemory, detectHashMemory } from "./hash-memory.js";
@@ -96,6 +108,7 @@ import {
} from "./hooks/handle-stream-events.js";
import { handleToolEvent } from "./hooks/handle-tool-event.js";
import { useAgentSession } from "./hooks/useAgentSession.js";
import { useChatScroll } from "./hooks/useChatScroll.js";
import { useCodeMode } from "./hooks/useCodeMode.js";
import { useInputRecall } from "./hooks/useInputRecall.js";
import { useLoopMode } from "./hooks/useLoopMode.js";
@@ -104,6 +117,7 @@ import { useScrollback } from "./hooks/useScrollback.js";
import { useTranscriptWriter } from "./hooks/useTranscriptWriter.js";
import { useKeystroke } from "./keystroke-context.js";
import { CardStream } from "./layout/CardStream.js";
import { LiveExpandContext } from "./layout/LiveExpandContext.js";
import {
ModeStatusBar,
OngoingToolRow,
@@ -126,6 +140,7 @@ import { TurnTranslator } from "./state/TurnTranslator.js";
import { cardsToDashboardMessages } from "./state/cards-to-messages.js";
import { hydrateCardsFromMessages } from "./state/hydrate.js";
import { AgentStoreProvider, useAgentState, useAgentStore } from "./state/provider.js";
import { FG } from "./theme/tokens.js";
import { TickerProvider } from "./ticker.js";
import { useCompletionPickers } from "./useCompletionPickers.js";
import { useEditHistory } from "./useEditHistory.js";
@@ -297,8 +312,15 @@ function AppInner({
s.cards.some((c) => c.kind === "user" || c.kind === "streaming"),
);
const isStreaming = useAgentState((s) => s.cards.some((c) => c.kind === "streaming" && !c.done));
const chatScroll = useChatScroll();
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
// ctrl-o toggles full-tail view on the live streaming card.
// Auto-resets at the end of every turn so the next reply starts collapsed.
const [liveExpand, setLiveExpand] = useState(false);
useEffect(() => {
if (!isStreaming && liveExpand) setLiveExpand(false);
}, [isStreaming, liveExpand]);
const [languageVersion, setLanguageVersion] = useState(0);
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
// Live MCP server list: initialized from the boot-time prop, then
@@ -359,39 +381,6 @@ function AppInner({
};
}, [stdout]);
// Resize-suppression state. While the user is dragging the
// terminal corner, OS emits a stream of resize events at high
// frequency. Each one would trigger Ink to re-render — and our
// per-tick animations (wordmark gradient, prompt bar flow,
// cursor blink) keep firing at 120 ms — both with stale
// `eraseLines(N)` counts because the previous frame's logical
// height is no longer the visible height after wrap reflows.
// Result: ghost copies of the StatsPanel pile up.
//
// Fix: detect resize bursts and freeze the global ticker while
// they're in flight. With the ticker frozen, no re-render fires
// from animations during the resize storm. Once the user stops
// dragging (no resize event for ~400 ms), the ticker resumes
// and one clean re-render kicks in. The hard-clear in chat.tsx's
// resize listener handles the single transition ghost.
const [isResizing, setIsResizing] = useState(false);
useEffect(() => {
if (!stdout || !stdout.isTTY) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
setIsResizing(true);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
setIsResizing(false);
timer = null;
}, 400);
};
stdout.on("resize", onResize);
return () => {
stdout.off("resize", onResize);
if (timer) clearTimeout(timer);
};
}, [stdout]);
// Subagent UI wiring: live activity row + sink ref the loop closure
// captures. Must be declared BEFORE loop construction so the
// subagentRunner closure can read the ref. The wallet-currency thunk
@@ -433,6 +422,7 @@ function AppInner({
undoBanner,
recordEdit,
armUndoBanner,
toggleUndoPause,
codeUndo,
codeHistory,
codeShowEdit,
@@ -545,8 +535,13 @@ function AppInner({
/** True while the SessionPicker is open mid-chat (triggered by `/sessions`). */
const [pendingSessionsPicker, setPendingSessionsPicker] = useState(false);
const [sessionsPickerList, setSessionsPickerList] = useState<ReturnType<typeof listSessions>>([]);
/** True while the CheckpointPicker is open mid-chat (triggered by bare `/restore`). */
const [pendingCheckpointPicker, setPendingCheckpointPicker] = useState(false);
const [checkpointPickerList, setCheckpointPickerList] = useState<CheckpointMeta[]>([]);
/** Opens the unified McpHub modal — null when closed. `tab` selects the initial tab. */
const [pendingMcpHub, setPendingMcpHub] = useState<{ tab: "live" | "marketplace" } | null>(null);
/** True while the ModelPicker is open mid-chat (triggered by bare `/model`). */
const [pendingModelPicker, setPendingModelPicker] = useState(false);
// Stashed plan + intent while the user types free-form feedback
// (refinement or last instructions on approve). When the picker
// returns "refine" or "approve", we defer the loop-resume and show
@@ -602,6 +597,24 @@ function AppInner({
options: ChoiceOption[];
allowCustom: boolean;
} | null>(null);
// Truthy when any pending modal owns the screen — gates global
// hotkeys (chat-scroll, etc.) so they don't fire behind a picker.
const modalOpen =
!!pendingShell ||
!!pendingPlan ||
!!pendingReviseEditor ||
!!pendingSessionsPicker ||
!!pendingCheckpointPicker ||
!!pendingMcpHub ||
pendingModelPicker ||
!!stagedInput ||
!!pendingEditReview ||
walkthroughActive ||
!!pendingChoice ||
!!stagedChoiceCustom ||
!!pendingRevision ||
!!stagedCheckpointRevise ||
!!pendingCheckpoint;
// Plan-mode indicator — displayed in the StatsPanel, mirrored onto
// the ToolRegistry so dispatch enforces read-only. Toggled via the
// `/plan` slash and PlanConfirm picker. Ephemeral — not persisted
@@ -666,6 +679,13 @@ function AppInner({
// sync with the TUI. The Set is keyed by the subscriber function
// itself; subscribeEvents returns an unsubscribe closure.
const eventSubscribersRef = useRef<Set<(ev: DashboardEvent) => void>>(new Set());
/** Only one picker mounts at a time; snapshot feeds `getActiveModal` for late SSE clients. */
const activePickerResolverRef = useRef<((res: PickerResolution) => void) | null>(null);
const activePickerSnapshotRef = useRef<PickerSnapshot | null>(null);
/** Active read-only viewer (e.g. /replay plan archive). Same late-SSE concern, simpler resolver (close only). */
const activeViewerResolverRef = useRef<(() => void) | null>(null);
const activeViewerSnapshotRef = useRef<ViewerSnapshot | null>(null);
const [pendingReplayViewer, setPendingReplayViewer] = useState<ViewerSnapshot | null>(null);
// Structured steps captured from the most recent `submit_plan` call.
// Populated only when the model supplied `steps`; used by the
// `mark_step_complete` handler to look up the step title and compute
@@ -890,6 +910,10 @@ function AppInner({
// balance. useSessionInfo refreshes balance every few minutes; we
// forward to the dashboard without re-minting startDashboard.
const balanceRef = useRef<typeof balance>(null);
const modelsRef = useRef<string[] | null>(null);
useEffect(() => {
modelsRef.current = models;
}, [models]);
useEffect(() => {
balanceRef.current = balance;
walletCurrencyRef.current = balance?.currency;
@@ -915,6 +939,28 @@ function AppInner({
}
}
}, []);
const pickerPorts = useMemo(
() => ({
broadcast: broadcastDashboardEvent,
resolverRef: activePickerResolverRef,
snapshotRef: activePickerSnapshotRef,
}),
[broadcastDashboardEvent],
);
const viewerPorts = useMemo(
() => ({
broadcast: broadcastDashboardEvent,
resolverRef: activeViewerResolverRef,
snapshotRef: activeViewerSnapshotRef,
}),
[broadcastDashboardEvent],
);
useViewerBroadcast(
!!pendingReplayViewer,
pendingReplayViewer ?? { viewerKind: "replay-plan", title: "" },
() => setPendingReplayViewer(null),
viewerPorts,
);
// Broadcast busy-state changes so the web Chat tab can disable its
// submit button while a turn is in flight. Mirrors what the TUI's
@@ -1077,15 +1123,11 @@ function AppInner({
if (sessionBannerShown.current) return;
sessionBannerShown.current = true;
if (!session) {
log.pushInfo("▸ ephemeral chat (no session persistence) — drop --no-session to enable");
log.pushInfo(t("ui.ephemeralSession"));
} else if (loop.resumedMessageCount > 0) {
log.pushInfo(
`▸ resumed session "${session}" with ${loop.resumedMessageCount} prior messages · /forget to start over · /sessions to list`,
);
log.pushInfo(t("ui.resumedSession", { name: session, count: loop.resumedMessageCount }));
} else {
log.pushInfo(
`▸ session "${session}" (new) — auto-saved as you chat · /forget to delete · /sessions to list`,
);
log.pushInfo(t("ui.newSession", { name: session }));
}
// Restore any pending edit queue from a prior run that was
// interrupted before /apply or /discard. The checkpoint file sits
@@ -1096,9 +1138,7 @@ function AppInner({
if (restored && restored.length > 0) {
pendingEdits.current = restored;
syncPendingCount();
log.pushInfo(
`▸ restored ${restored.length} pending edit block(s) from an interrupted prior run — /apply to commit or /discard to drop.`,
);
log.pushInfo(t("ui.restoredEdits", { count: restored.length }));
}
}
// Restore structured plan state from a prior run. plan.json sits
@@ -1121,7 +1161,7 @@ function AppInner({
const done = new Set(restoredPlan.completedStepIds);
const summary = restoredPlan.summary ? ` — ${restoredPlan.summary}` : "";
log.showPlan({
title: `Resumed plan · ${when}${summary}`,
title: t("ui.resumedPlan", { when, summary }),
steps: restoredPlan.steps.map((s) => ({
id: s.id,
title: s.title,
@@ -1137,14 +1177,7 @@ function AppInner({
// per install; the config flag suppresses re-display on every
// relaunch. Skips chat mode — those shortcuts don't apply there.
if (codeMode && !editModeHintShown()) {
log.pushInfo(
"▸ TIP: edit-gate keybindings\n" +
" y / n accept or drop pending edits\n" +
" Shift+Tab switch review ↔ AUTO (persisted; AUTO applies instantly)\n" +
" u undo the last auto-applied batch (within the 5s banner)\n" +
" Current mode is shown in the bottom status bar. Run /keys anytime for the full list.\n" +
" (This tip shows once — suppressed after.)",
);
log.pushInfo(t("ui.tipEditBindings"));
markEditModeHintShown();
}
}, [session, loop, codeMode, syncPendingCount, log]);
@@ -1152,6 +1185,20 @@ function AppInner({
// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
const quitProcess = useQuit(transcriptRef);
// PgUp / PgDn always scroll chat history; ↑/↓-on-empty-buffer also
// routes here via PromptInput's chatScrollHandoff. WT translates wheel
// events to ↑/↓ in raw mode, so this is what makes wheel-scroll work.
useKeystroke((ev) => {
if (ev.pageUp) chatScroll.scrollUp();
else if (ev.pageDown) chatScroll.scrollDown();
else if (ev.end) chatScroll.jumpToBottom();
// Wheel-translated ↑/↓ has nowhere to go when PromptInput can't
// process it: in reading mode (unmounted) or while busy (disabled).
// When pinned + idle, PromptInput owns arrows for cursor / handoff.
else if ((!chatScroll.pinned || busy) && ev.upArrow) chatScroll.scrollUp();
else if ((!chatScroll.pinned || busy) && ev.downArrow) chatScroll.scrollDown();
}, !modalOpen);
// Esc during busy → forward to the loop as an abort signal. The loop
// finishes the tool call in flight (we can't kill subprocess stdio
// mid-write), then diverts to its no-tools summary path so the user
@@ -1220,8 +1267,8 @@ function AppInner({
const remaining = pendingEdits.current.length;
log.pushInfo(
remaining > 0
? `▸ walk cancelled — ${remaining} block(s) still pending.`
: "▸ walk cancelled.",
? t("app.walkCancelledRemaining", { count: remaining })
: t("app.walkCancelled"),
);
return;
}
@@ -1237,6 +1284,7 @@ function AppInner({
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
@@ -1252,10 +1300,10 @@ function AppInner({
setEditMode(next);
const message =
next === "yolo"
? "▸ edit mode: YOLO — edits AND shell commands auto-run. /undo still rolls back edits. Use carefully."
? t("app.editModeYolo")
: next === "auto"
? "▸ edit mode: AUTO — edits apply immediately; press u within 5s to undo. Shell commands still ask."
: "▸ edit mode: review — edits queue for /apply (or y) / /discard (or n)";
? t("app.editModeAuto")
: t("app.editModeReview");
log.pushInfo(message);
return;
}
@@ -1271,6 +1319,7 @@ function AppInner({
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
@@ -1287,6 +1336,52 @@ function AppInner({
log.pushInfo(out);
return;
}
// Space toggles pause on the active undo countdown. Same gating as
// the `u` keybind so typing in the prompt isn't intercepted.
if (
codeMode &&
input.length === 0 &&
chKey === " " &&
undoBanner &&
!pendingShell &&
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
!walkthroughActive &&
!pendingChoice &&
!stagedChoiceCustom &&
!pendingRevision
) {
toggleUndoPause();
return;
}
// Ctrl-O toggles full-tail view on the live streaming reply so a long
// plan / todo can be read while it's still being written. Resets at
// turn end so each new reply starts collapsed.
if (
key.ctrl &&
key.input === "o" &&
isStreaming &&
!pendingShell &&
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
!walkthroughActive &&
!pendingChoice &&
!stagedChoiceCustom &&
!pendingRevision
) {
setLiveExpand((v) => !v);
return;
}
if (busy) return;
// ShellConfirm owns the full keyboard while it's showing. If we
// kept handling ↑/↓ / Tab here they'd race with its SingleSelect
@@ -1456,17 +1551,17 @@ function AppInner({
if (choice === "reject") {
const context = denyContext ? ` because: ${denyContext}` : "";
log.pushInfo(`▸ rejected edit to ${block.path}${context}`);
log.pushInfo(t("app.rejectedEdit", { path: block.path, context }));
return `User rejected this edit to ${block.path}${context}. Don't retry the same SEARCH/REPLACE — either try a different approach or ask the user what they want instead.`;
}
if (choice === "apply-rest-of-turn") {
turnEditPolicyRef.current = "apply-all";
log.pushInfo("▸ auto-approving remaining edits for this turn");
log.pushInfo(t("app.autoApprovingRest"));
return applyNow();
}
if (choice === "flip-to-auto") {
setEditMode("auto");
log.pushInfo("▸ flipped to AUTO mode for the rest of the session (persisted)");
log.pushInfo(t("app.flippedAutoSession"));
return applyNow();
}
// "apply"
@@ -1574,6 +1669,7 @@ function AppInner({
autoEscalate: settings.autoEscalate,
reasoningEffort: settings.reasoningEffort,
});
agentStore.dispatch({ type: "session.model.change", model: settings.model });
const canonical: "auto" | "flash" | "pro" =
settings.model === "deepseek-v4-pro" ? "pro" : settings.autoEscalate ? "auto" : "flash";
setPreset(canonical);
@@ -1581,6 +1677,21 @@ function AppInner({
applyEffortLive: (effort) => {
loop.configure({ reasoningEffort: effort });
},
applyModelLive: (model) => {
loop.configure({ model });
agentStore.dispatch({ type: "session.model.change", model });
},
getModels: () => modelsRef.current,
setProNextLive: (armed) => {
if (armed) loop.armProForNextTurn();
else loop.disarmPro();
},
setBudgetUsdLive: (usd) => {
loop.setBudget(usd);
},
getLoopRunStatus: () => getLoopStatus(),
startAutoLoop: (intervalMs, prompt) => startLoop(intervalMs, prompt),
stopAutoLoop: () => stopLoop(),
// ---------- Chat bridge ----------
getMessages: (): DashboardMessage[] =>
cardsToDashboardMessages(agentStore.getState().cards),
@@ -1685,6 +1796,14 @@ function AppInner({
...(pendingRevision.summary ? { summary: pendingRevision.summary } : {}),
};
}
const picker = activePickerSnapshotRef.current;
if (picker) {
return { kind: "picker", ...picker };
}
const viewer = activeViewerSnapshotRef.current;
if (viewer) {
return { kind: "viewer", ...viewer };
}
return null;
},
resolveShellConfirm: (choice) => {
@@ -1736,6 +1855,14 @@ function AppInner({
resolveReviseConfirm: (choice) => {
Promise.resolve(handleReviseConfirmRef.current(choice)).catch(() => undefined);
},
resolvePicker: (resolution) => {
const fn = activePickerResolverRef.current;
if (fn) Promise.resolve(fn(resolution)).catch(() => undefined);
},
resolveViewer: () => {
const fn = activeViewerResolverRef.current;
if (fn) Promise.resolve(fn()).catch(() => undefined);
},
// ---------- v0.14 mutation surface ----------
reloadHooks: () => {
const fresh = loadHooks({
@@ -1777,6 +1904,9 @@ function AppInner({
pendingRevision,
agentStore,
mcpRuntime,
getLoopStatus,
startLoop,
stopLoop,
]);
const stopDashboard = useCallback(async (): Promise<void> => {
@@ -1789,7 +1919,7 @@ function AppInner({
} catch {
/* swallow — server going down is best-effort */
}
log.pushInfo("▸ dashboard stopped.");
log.pushInfo(t("app.dashboardStopped"));
}, [log]);
const getDashboardUrl = useCallback((): string | null => {
@@ -1818,9 +1948,7 @@ function AppInner({
// web UI is unreachable — port already in use, permission
// denied, etc. Don't block the TUI; everything else keeps working.
const reason = err instanceof Error ? err.message : String(err);
log.pushInfo(
`▲ dashboard auto-start failed (${reason}) — try /dashboard, or pass --no-dashboard to silence`,
);
log.pushInfo(t("ui.dashboardAutoStartFailed", { reason }));
});
}, [noDashboard, startDashboard, log]);
@@ -1862,7 +1990,7 @@ function AppInner({
setEditMode("auto");
saveEditMode("auto");
log.pushInfo(codeApply([1]));
log.pushInfo("▸ flipped to AUTO mode — future edits will apply immediately. Walk exited.");
log.pushInfo(t("app.flippedAutoWalk"));
setWalkthroughActive(false);
return;
}
@@ -1960,11 +2088,11 @@ function AppInner({
const result = isGlobal
? appendGlobalMemory(hashParse.note)
: appendProjectMemory(memRoot, hashParse.note);
const verb = result.created ? "created" : "appended to";
const scopeTag = isGlobal ? "global" : "project";
log.pushInfo(`▸ noted (${scopeTag}) — ${verb} ${result.path}`);
const verb = result.created ? t("app.notedVerbCreated") : t("app.notedVerbAppended");
const scopeTag = isGlobal ? t("app.notedScopeGlobal") : t("app.notedScopeProject");
log.pushInfo(t("app.notedMemory", { scope: scopeTag, verb, path: result.path }));
} catch (err) {
log.pushWarning("# memory write failed", (err as Error).message);
log.pushWarning(t("app.memoryWriteFailed"), (err as Error).message);
}
return;
}
@@ -2000,7 +2128,7 @@ function AppInner({
content: formatBangUserMessage(bangCmd, formatted),
});
} catch (err) {
log.pushWarning("! command failed", (err as Error).message);
log.pushWarning(t("app.commandFailed"), (err as Error).message);
} finally {
setBusy(false);
}
@@ -2095,16 +2223,48 @@ function AppInner({
pushHistory(text);
return;
}
if (result.openCheckpointPicker) {
if (!codeMode) {
log.pushInfo(t("app.restoreCodeOnly"));
pushHistory(text);
return;
}
setCheckpointPickerList([...listCheckpoints(currentRootDir)].reverse());
setPendingCheckpointPicker(true);
pushHistory(text);
return;
}
if (result.openMcpHub) {
setPendingMcpHub({ tab: result.openMcpHub.tab });
pushHistory(text);
return;
}
if (result.openModelPicker) {
setPendingModelPicker(true);
pushHistory(text);
return;
}
if (result.openArgPickerFor) {
pushHistory(text);
setInput(`/${result.openArgPickerFor} `);
return;
}
if (result.replayPlan) {
const rp = result.replayPlan;
const titleSuffix = rp.summary ? ` — ${rp.summary}` : "";
const done = new Set(rp.completedStepIds);
setPendingReplayViewer({
viewerKind: "replay-plan",
title: `Replay #${rp.index}/${rp.total} · ${rp.relativeTime}${titleSuffix}`,
body: rp.body,
steps: rp.steps.map((s) => ({
id: s.id,
title: s.title,
status: done.has(s.id) ? "done" : "queued",
})),
meta: rp.archiveBasename,
});
}
const outcome = applySlashResult(result, {
log,
stdoutWrite: (chunk) => stdout?.write(chunk),
@@ -2138,7 +2298,7 @@ function AppInner({
});
for (const o of promptReport.outcomes) {
if (o.decision === "pass") continue;
log.pushWarning("UserPromptSubmit hook", formatHookOutcomeMessage(o));
log.pushWarning(t("app.hookUserPromptSubmit"), formatHookOutcomeMessage(o));
}
if (promptReport.blocked) return;
}
@@ -2201,7 +2361,7 @@ function AppInner({
const flush = () => {
if (!contentBuf.current && !reasoningBuf.current && !toolCallBuildBuf.current) return;
translator.flushBuffers(reasoningBuf.current, contentBuf.current);
translator.flushBuffers(reasoningBuf.current, contentBuf.current, loop.currentCallModel);
streamRef.text += contentBuf.current;
streamRef.reasoning += reasoningBuf.current;
if (toolCallBuildBuf.current) {
@@ -2233,7 +2393,7 @@ function AppInner({
const parts: string[] = [];
if (inlined.length > 0) parts.push(`inlined ${inlined.join(", ")}`);
if (skipped.length > 0) parts.push(`skipped ${skipped.join(", ")}`);
if (parts.length > 0) log.pushInfo(`▸ @mentions: ${parts.join("; ")}`);
if (parts.length > 0) log.pushInfo(t("app.atMentions", { parts: parts.join("; ") }));
}
}
// Expand `@http(s)://...` URL mentions. Available in any mode (chat
@@ -2262,12 +2422,12 @@ function AppInner({
const parts: string[] = [];
if (inlined.length > 0) parts.push(`inlined ${inlined.join("; ")}`);
if (skipped.length > 0) parts.push(`skipped ${skipped.join("; ")}`);
if (parts.length > 0) log.pushInfo(`▸ @url: ${parts.join("; ")}`);
if (parts.length > 0) log.pushInfo(t("app.atUrl", { parts: parts.join("; ") }));
}
} catch (err) {
// expandAtUrls itself only throws on misconfiguration (no
// fetcher). Per-URL failures are surfaced via the skip path.
log.pushWarning("@url expansion failed", (err as Error).message);
log.pushWarning(t("app.atUrlFailed"), (err as Error).message);
}
}
@@ -2359,7 +2519,18 @@ function AppInner({
const m = loadSessionMeta(session);
const cost = (m.totalCostUsd ?? 0) + (ev.stats?.cost ?? 0);
const turn = (m.turnCount ?? 0) + 1;
patchSessionMeta(session, { totalCostUsd: cost, turnCount: turn });
const currency = walletCurrencyRef.current;
const u = ev.stats?.usage;
const cacheHitTokens = (m.cacheHitTokens ?? 0) + (u?.promptCacheHitTokens ?? 0);
const cacheMissTokens = (m.cacheMissTokens ?? 0) + (u?.promptCacheMissTokens ?? 0);
patchSessionMeta(session, {
totalCostUsd: cost,
turnCount: turn,
cacheHitTokens,
cacheMissTokens,
...(u?.promptTokens ? { lastPromptTokens: u.promptTokens } : {}),
...(currency ? { balanceCurrency: currency } : {}),
});
}
} else if (ev.role === "tool_start") {
handleToolStart(ev, {
@@ -2415,7 +2586,7 @@ function AppInner({
});
for (const o of stopReport.outcomes) {
if (o.decision === "pass") continue;
log.pushWarning("Stop hook", formatHookOutcomeMessage(o));
log.pushWarning(t("app.hookStop"), formatHookOutcomeMessage(o));
}
}
} finally {
@@ -2527,15 +2698,17 @@ function AppInner({
if (choice === "deny") {
const context = denyContext ? ` because: ${denyContext}` : "";
log.pushInfo(`▸ denied: ${cmd}${context}`);
log.pushInfo(t("app.denied", { cmd, context }));
pauseGate.resolve(id, { type: "deny", denyContext });
} else if (choice === "always_allow") {
const prefix = derivePrefix(cmd);
log.pushInfo(`▸ always allowed "${prefix}" for ${currentRootDir}`);
log.pushInfo(t("app.alwaysAllowed", { prefix, dir: currentRootDir }));
pauseGate.resolve(id, { type: "always_allow", prefix });
} else {
log.pushInfo(
kind === "run_background" ? `▸ starting (background): ${cmd}` : `▸ running: ${cmd}`,
kind === "run_background"
? t("app.startingBackground", { cmd })
: t("app.runningCommand", { cmd }),
);
pauseGate.resolve(id, { type: "run_once" });
}
@@ -2875,7 +3048,11 @@ function AppInner({
source: "auto-pre-restore",
});
log.pushInfo(
`⛁ checkpoint saved · ${meta.id} · ${meta.fileCount} file${meta.fileCount === 1 ? "" : "s"} · /restore ${meta.id} to roll back this step`,
t("app.checkpointSaved", {
id: meta.id,
count: meta.fileCount,
s: meta.fileCount === 1 ? "" : "s",
}),
);
} catch {
/* best-effort */
@@ -2891,8 +3068,8 @@ function AppInner({
const counter = snap.total > 0 ? ` (${snap.completed}/${snap.total})` : "";
log.pushInfo(
choice === "continue"
? `▸ continuing after ${label}${counter}`
: `▸ plan stopped at ${label}${counter}`,
? t("app.continuingAfter", { label, counter })
: t("app.planStoppedAt", { label, counter }),
);
},
[pendingCheckpoint, codeMode, touchedPaths, log],
@@ -2923,8 +3100,11 @@ function AppInner({
);
}
const marker = trimmed
? `▸ revising after ${label} — ${trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed}`
: `▸ continuing after ${label}`;
? t("app.revisingAfter", {
label,
feedback: trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed,
})
: t("app.continuingAfter", { label, counter: "" });
log.pushInfo(marker);
},
[log],
@@ -3003,40 +3183,25 @@ function AppInner({
[],
);
// Suspend cosmetic animations during modal interactions and idle so
// a quiescent TUI is byte-stable. PLAIN_UI is the env-flag opt-out
// for fragile terminals.
const tickerSuspended = PLAIN_UI || modalOpen || (!busy && !isStreaming);
return (
<>
<TickerProvider
disabled={
PLAIN_UI ||
isResizing ||
!!pendingPlan ||
!!pendingReviseEditor ||
pendingSessionsPicker ||
!!pendingMcpHub ||
!!pendingShell ||
!!pendingEditReview ||
walkthroughActive ||
!!pendingChoice ||
!!stagedChoiceCustom ||
!!pendingRevision ||
!!stagedCheckpointRevise ||
!!pendingCheckpoint ||
// Idle gate: when nothing is actively happening, suspend the
// 8Hz/1Hz heartbeats. The cursor blink, gradient pulse, and
// spinner glyphs are pure cosmetics — running them at idle
// forces Ink to repaint the screen ~8x/sec, which erases any
// text selection the user has made in the terminal. With the
// ticker paused, an idle TUI is byte-stable and shift-drag /
// click-drag selections survive until something actually
// changes (incoming stream, key press, modal popup).
(!busy && !isStreaming)
}
>
<TickerProvider disabled={tickerSuspended}>
<ViewportBudgetProvider>
<Box flexDirection="row">
<Box flexDirection="row" height={stdout?.rows ?? 24}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="column">
<CardStream />
<Box flexDirection="column" flexGrow={1}>
<LiveExpandContext.Provider value={liveExpand}>
<CardStream
suppressLive={modalOpen}
scrollRows={chatScroll.scrollRows}
onMaxScrollChange={chatScroll.setMaxScroll}
/>
</LiveExpandContext.Provider>
{/*
Welcome card on the empty state. Visible only when nothing
has happened yet (no past events, nothing in flight, no
@@ -3046,6 +3211,7 @@ function AppInner({
{!hasConversation && !busy && !isStreaming ? (
<WelcomeBanner
inCodeMode={!!codeMode}
workspaceRoot={codeMode ? currentRootDir : undefined}
dashboardUrl={dashboardUrl}
languageVersion={languageVersion}
/>
@@ -3062,6 +3228,7 @@ function AppInner({
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
@@ -3073,6 +3240,7 @@ function AppInner({
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
@@ -3084,6 +3252,7 @@ function AppInner({
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
@@ -3097,6 +3266,7 @@ function AppInner({
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
@@ -3120,6 +3290,7 @@ function AppInner({
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview &&
@@ -3134,6 +3305,7 @@ function AppInner({
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview ? (
@@ -3186,10 +3358,56 @@ function AppInner({
completedStepIds={completedStepIdsRef.current}
onChoose={stableHandleCheckpointConfirm}
/>
) : pendingCheckpointPicker ? (
<CheckpointPicker
checkpoints={checkpointPickerList}
workspace={currentRootDir}
pickerPorts={pickerPorts}
onChoose={(outcome) => {
if (outcome.kind === "quit") {
setPendingCheckpointPicker(false);
return;
}
if (outcome.kind === "restore") {
const target = checkpointPickerList.find((c) => c.id === outcome.id);
setPendingCheckpointPicker(false);
if (!target) return;
const result = restoreCheckpoint(currentRootDir, target.id);
const lines = [
`▸ restored "${target.name}" (${target.id.slice(0, 7)}, ${fmtAgo(target.createdAt)})`,
];
if (result.restored.length > 0) {
lines.push(
` wrote ${result.restored.length} file${result.restored.length === 1 ? "" : "s"}`,
);
}
if (result.removed.length > 0) {
lines.push(
` removed ${result.removed.length} file${result.removed.length === 1 ? "" : "s"}`,
);
}
if (result.skipped.length > 0) {
lines.push(
` skipped ${result.skipped.length} file${result.skipped.length === 1 ? "" : "s"}`,
);
}
log.pushInfo(lines.join("\n"));
return;
}
if (outcome.kind === "delete") {
const target = checkpointPickerList.find((c) => c.id === outcome.id);
if (!target) return;
deleteCheckpoint(currentRootDir, target.id);
setCheckpointPickerList([...listCheckpoints(currentRootDir)].reverse());
}
}}
/>
) : pendingSessionsPicker ? (
<SessionPicker
sessions={sessionsPickerList}
workspace={currentRootDir}
walletCurrency={walletCurrencyRef.current}
pickerPorts={pickerPorts}
onChoose={(outcome) => {
if (outcome.kind === "open") {
setPendingSessionsPicker(false);
@@ -3228,11 +3446,26 @@ function AppInner({
}
}}
/>
) : pendingModelPicker ? (
<ModelPicker
models={models}
current={loop.model}
onRefresh={refreshModels}
onChoose={(outcome) => {
setPendingModelPicker(false);
if (outcome.kind === "select") {
loop.configure({ model: outcome.id });
agentStore.dispatch({ type: "session.model.change", model: outcome.id });
log.pushInfo(`▸ model: ${outcome.id}`);
}
}}
/>
) : pendingMcpHub ? (
<McpHub
initialTab={pendingMcpHub.tab}
liveServers={liveMcpServers}
configPath={defaultConfigPath()}
pickerPorts={pickerPorts}
onClose={() => setPendingMcpHub(null)}
postInfo={(text) => log.pushInfo(text)}
applyAppend={(target, addedTools) => {
@@ -3304,6 +3537,10 @@ function AppInner({
block={pendingEdits.current[0]!}
onChoose={handleWalkChoice}
/>
) : !chatScroll.pinned ? (
<Text color={FG.faint}>
{" 📖 reading history — End / PgDn to return · ↓ to advance one line"}
</Text>
) : (
<>
{codeMode ? (
@@ -3325,6 +3562,8 @@ function AppInner({
disabled={busy}
onHistoryPrev={recallPrev}
onHistoryNext={recallNext}
onChatScrollUp={chatScroll.scrollUp}
onChatScrollDown={chatScroll.scrollDown}
/>
{slashMatches !== null ? (
<SlashSuggestions matches={slashMatches} selectedIndex={slashSelected} />
+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>
);
}
-1
View File
@@ -1,6 +1,5 @@
/** Modal picker for `ask_choice` — options + optional "type my own" escape hatch. */
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import type { ChoiceOption } from "../../tools/choice.js";
import { SingleSelect } from "./Select.js";
-1
View File
@@ -1,7 +1,6 @@
/** `/mcp` browser modal — keyboard-driven server list per design §24. */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { useKeystroke } from "./keystroke-context.js";
import { toggleMcpDisabled } from "./mcp-disable.js";
+10 -2
View File
@@ -1,10 +1,10 @@
/** `/mcp` slash modal — single hub with two tabs: Live (attached servers) + Marketplace (registry). */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { McpBrowser } from "./McpBrowser.js";
import { McpMarketplace } from "./McpMarketplace.js";
import type { PickerBroadcastPorts } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import type { ApplyAppend } from "./mcp-reconnect-kickoff.js";
import type { McpServerSummary } from "./slash/types.js";
@@ -24,6 +24,8 @@ export interface McpHubProps {
removed: string[];
failed: Array<{ spec: string; reason: string }>;
}>;
/** Forwarded to the marketplace tab so the web dashboard can drive install / uninstall / refine / load-more. */
pickerPorts?: PickerBroadcastPorts;
}
export function McpHub({
@@ -34,6 +36,7 @@ export function McpHub({
postInfo,
applyAppend,
reloadMcp,
pickerPorts,
}: McpHubProps) {
const [tab, setTab] = useState<McpHubTab>(initialTab);
@@ -65,7 +68,12 @@ export function McpHub({
applyAppend={applyAppend}
/>
) : (
<McpMarketplace onClose={onClose} postInfo={postInfo} reloadMcp={reloadMcp} />
<McpMarketplace
onClose={onClose}
postInfo={postInfo}
reloadMcp={reloadMcp}
pickerPorts={pickerPorts}
/>
)}
</Box>
);
+120 -28
View File
@@ -1,7 +1,6 @@
/** `/mcp browse` modal — registry marketplace inside the chat session. */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { readConfig, writeConfig } from "../../config.js";
import {
@@ -12,6 +11,7 @@ import {
specStringFor,
} from "../../mcp/registry-fetch.js";
import type { RegistryEntry } from "../../mcp/registry-types.js";
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import { COLOR } from "./theme.js";
@@ -27,6 +27,7 @@ export interface McpMarketplaceProps {
removed: string[];
failed: Array<{ spec: string; reason: string }>;
}>;
pickerPorts?: PickerBroadcastPorts;
}
interface State {
@@ -39,6 +40,39 @@ interface State {
installedSpecs: string[];
}
export function buildMarketplacePickerSnapshot(args: {
filtered: RegistryEntry[];
installedSpecs: string[];
query: string;
status: string;
hasMore: boolean;
}) {
return {
pickerKind: "mcp-marketplace" as const,
title: `MCP marketplace · ${args.status}`,
query: args.query,
items: args.filtered.map((e) => {
const installedSpec = isInstalled(args.installedSpecs, e);
return {
id: e.name,
title: e.title || e.name,
subtitle: e.description?.slice(0, 200) ?? undefined,
badge: installedSpec
? "installed"
: e.source === "official"
? "official"
: e.source === "smithery"
? "smithery"
: "local",
meta: e.popularity !== undefined ? `★ ${e.popularity.toLocaleString()}` : undefined,
};
}),
actions: ["install", "uninstall", "refine", "load-more", "cancel"] as const,
hasMore: args.hasMore,
hint: "type filter · ↑↓ pick · ⏎ install/toggle · PgDn load more · esc close",
};
}
function rankAndFilter(entries: RegistryEntry[], query: string): RegistryEntry[] {
const q = query.trim().toLowerCase();
const list = q
@@ -66,7 +100,7 @@ function isInstalled(installedSpecs: string[], entry: RegistryEntry): string | n
}
}
export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceProps) {
export function McpMarketplace({ onClose, postInfo, reloadMcp, pickerPorts }: McpMarketplaceProps) {
const [state, setState] = useState<State>({
handle: null,
loading: true,
@@ -128,33 +162,30 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
}
}, [state.handle, state.loading]);
const installOrToggle = useCallback(
async (entry: RegistryEntry) => {
const installed = isInstalled(state.installedSpecs, entry);
if (installed) {
const cfg = readConfig();
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
writeConfig({ ...cfg, mcp: next });
setState((s) => ({
...s,
installedSpecs: next,
status: `uninstalled ${entry.name}`,
}));
if (reloadMcp) {
try {
await reloadMcp();
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
} catch (err) {
postInfo(
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
);
}
} else {
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
const doUninstall = useCallback(
async (entry: RegistryEntry, installed: string) => {
const cfg = readConfig();
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
writeConfig({ ...cfg, mcp: next });
setState((s) => ({ ...s, installedSpecs: next, status: `uninstalled ${entry.name}` }));
if (reloadMcp) {
try {
await reloadMcp();
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
} catch (err) {
postInfo(
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
);
}
return;
} else {
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
}
},
[postInfo, reloadMcp],
);
const doInstall = useCallback(
async (entry: RegistryEntry) => {
let install = entry.install;
if (!install && entry.source === "smithery") {
setState((s) => ({ ...s, loading: true, status: "fetching smithery detail…" }));
@@ -176,7 +207,6 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
}));
return;
}
try {
const spec = specStringFor(entry.name, install);
const cfg = readConfig();
@@ -216,7 +246,69 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
setState((s) => ({ ...s, status: `install failed: ${(err as Error).message}` }));
}
},
[state.installedSpecs, postInfo, reloadMcp],
[postInfo, reloadMcp],
);
const installOrToggle = useCallback(
async (entry: RegistryEntry) => {
const installed = isInstalled(state.installedSpecs, entry);
if (installed) await doUninstall(entry, installed);
else await doInstall(entry);
},
[state.installedSpecs, doInstall, doUninstall],
);
const pickerSnapshot = useMemo(
() =>
buildMarketplacePickerSnapshot({
filtered,
installedSpecs: state.installedSpecs,
query: state.query,
status: state.status,
hasMore: state.handle?.cache.pagination.nextCursor != null,
}),
[filtered, state.installedSpecs, state.handle, state.query, state.status],
);
usePickerBroadcast(
!!pickerPorts,
{ ...pickerSnapshot, actions: [...pickerSnapshot.actions] },
(res) => {
if (res.action === "cancel") return onClose();
if (res.action === "refine") {
setState((s) => ({ ...s, query: res.query, selected: 0 }));
return;
}
if (res.action === "load-more") {
void fetchMore();
return;
}
if (res.action === "install") {
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
if (!entry) return;
if (isInstalled(state.installedSpecs, entry)) {
setState((s) => ({ ...s, status: `already installed: ${entry.name}` }));
return;
}
void doInstall(entry);
return;
}
if (res.action === "uninstall") {
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
if (!entry) return;
const installed = isInstalled(state.installedSpecs, entry);
if (!installed) {
setState((s) => ({ ...s, status: `not installed: ${entry.name}` }));
return;
}
void doUninstall(entry, installed);
}
},
pickerPorts ?? {
broadcast: () => undefined,
resolverRef: { current: null },
snapshotRef: { current: null },
},
);
useKeystroke((ev) => {
+125
View File
@@ -0,0 +1,125 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { useKeystroke } from "./keystroke-context.js";
import { PILL_MODEL, Pill, modelBadgeFor } from "./primitives/Pill.js";
import { FG, TONE } from "./theme/tokens.js";
export type ModelPickerOutcome = { kind: "select"; id: string } | { kind: "quit" };
export interface ModelPickerProps {
/** API-fetched ids; null means "still loading / offline". */
models: ReadonlyArray<string> | null;
/** Model id currently active in the loop — marked with the cursor on open. */
current: string;
onChoose: (outcome: ModelPickerOutcome) => void;
/** Triggers a refetch when the catalog is null/empty and the user presses [r]. */
onRefresh?: () => void;
}
const PAGE_MARGIN = 6;
export function ModelPicker({
models,
current,
onChoose,
onRefresh,
}: ModelPickerProps): React.ReactElement {
const list = (models && models.length > 0 ? models : FALLBACK_MODELS).slice();
if (!list.includes(current)) list.unshift(current);
const initialIndex = Math.max(0, list.indexOf(current));
const [focus, setFocus] = useState(initialIndex);
const { stdout } = useStdout();
const rows = stdout?.rows ?? 40;
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
useKeystroke((ev) => {
if (ev.escape) return onChoose({ kind: "quit" });
if (ev.upArrow) return setFocus((f) => Math.max(0, f - 1));
if (ev.downArrow) return setFocus((f) => Math.min(list.length - 1, f + 1));
if (ev.return) {
const target = list[focus];
if (target) onChoose({ kind: "select", id: target });
return;
}
if (!ev.input) return;
if (ev.input === "q") return onChoose({ kind: "quit" });
if (ev.input === "r") onRefresh?.();
});
const start = Math.max(
0,
Math.min(focus - Math.floor(visibleCount / 2), list.length - visibleCount),
);
const end = Math.min(list.length, start + visibleCount);
const shown = list.slice(start, end);
const hiddenAbove = start;
const hiddenBelow = list.length - end;
const loading = models === null;
const empty = models !== null && models.length === 0;
return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text bold color={TONE.brand}>
{" ◈ REASONIX · pick a model "}
</Text>
<Text color={FG.meta}>
{loading
? " · loading catalog…"
: empty
? " · catalog empty — using known fallbacks"
: ` · ${list.length} available`}
</Text>
</Box>
<Box height={1} />
{hiddenAbove > 0 ? (
<Box>
<Text color={FG.faint}>{` … ${hiddenAbove} earlier`}</Text>
</Box>
) : null}
{shown.map((id, i) => (
<ModelRow key={id} id={id} focused={start + i === focus} active={id === current} />
))}
{hiddenBelow > 0 ? (
<Box>
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
</Box>
) : null}
<Box marginTop={1}>
<Text color={FG.faint}>{" ↑↓ pick · ⏎ confirm · [r] refresh · esc cancel"}</Text>
</Box>
</Box>
);
}
function ModelRow({
id,
focused,
active,
}: {
id: string;
focused: boolean;
active: boolean;
}): React.ReactElement {
const badge = modelBadgeFor(id);
return (
<Box>
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
{id.padEnd(24)}
</Text>
<Text> </Text>
<Pill label={badge.label} {...PILL_MODEL[badge.kind]} bold={false} />
{active ? <Text color={TONE.brand}>{" · current"}</Text> : null}
</Box>
);
}
/** Hard-coded known DeepSeek ids — used when the API catalog hasn't loaded yet so the picker isn't empty on first open. */
const FALLBACK_MODELS: ReadonlyArray<string> = [
"deepseek-v4-flash",
"deepseek-v4-pro",
"deepseek-chat",
"deepseek-reasoner",
];
-1
View File
@@ -1,7 +1,6 @@
/** Modal picker for `PlanCheckpointError`: continue / revise / stop. */
import { Box } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import type { PlanStep } from "../../tools/plan.js";
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
+26 -5
View File
@@ -1,14 +1,14 @@
/** Modal-style picker for `submit_plan`: accept / refine / cancel. */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import type { PlanStep } from "../../tools/plan.js";
import { PlanStepList } from "./PlanStepList.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { CARD, TONE } from "./theme/tokens.js";
import { MarkdownView } from "./markdown-view.js";
import { CARD, FG, TONE } from "./theme/tokens.js";
export type PlanConfirmChoice = "approve" | "refine" | "revise" | "cancel";
@@ -21,9 +21,19 @@ export interface PlanConfirmProps {
projectRoot?: string;
}
const PLAN_BODY_PREVIEW_LINES = 24;
function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
const stepRows = steps?.length ?? 0;
useReserveRows("modal", { min: 10, max: Math.max(16, stepRows + 14) });
const hasSteps = stepRows > 0;
const planLines = plan.split("\n");
const truncatedBody = planLines.length > PLAN_BODY_PREVIEW_LINES;
const previewBody = truncatedBody ? planLines.slice(0, PLAN_BODY_PREVIEW_LINES).join("\n") : plan;
const previewRows = truncatedBody
? PLAN_BODY_PREVIEW_LINES
: Math.min(planLines.length, PLAN_BODY_PREVIEW_LINES);
const reservedFor = hasSteps ? stepRows : previewRows;
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + 14) });
const hasOpenQuestions =
/^#{1,6}\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear)/im.test(plan) ||
@@ -45,9 +55,20 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
</Text>
</Box>
) : null}
{steps && steps.length > 0 ? (
{hasSteps ? (
<Box marginBottom={1} flexDirection="column">
<PlanStepList steps={steps} />
<PlanStepList steps={steps!} />
</Box>
) : plan.trim().length > 0 ? (
<Box marginBottom={1} flexDirection="column">
<MarkdownView text={previewBody} />
{truncatedBody ? (
<Text color={FG.faint}>
{`… ${planLines.length - PLAN_BODY_PREVIEW_LINES} more line${
planLines.length - PLAN_BODY_PREVIEW_LINES === 1 ? "" : "s"
} above in scrollback`}
</Text>
) : null}
</Box>
) : null}
<SingleSelect
+31 -17
View File
@@ -1,7 +1,6 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform compiles JSX to React.createElement)
import React, { useRef, useState } from "react";
import { useCursor } from "../../renderer/index.js";
import { useKeystroke } from "./keystroke-context.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { type MultilineKey, lineAndColumn, processMultilineKey } from "./multiline-keys.js";
@@ -20,20 +19,25 @@ import { FG, SURFACE, TONE } from "./theme/tokens.js";
/** Raw-stdin keystroke bus → multiline reducer; one logical line per Box row, viewport-clipped. */
/** Pastes shorter than this AND single-line render verbatim; longer ones become a `[paste #N · …]` sentinel chip (#397). */
export const INLINE_PASTE_THRESHOLD = 200;
export function shouldInlinePaste(content: string): boolean {
return !content.includes("\n") && content.length <= INLINE_PASTE_THRESHOLD;
}
export interface PromptInputProps {
value: string;
onChange: (v: string) => void;
onSubmit: (v: string) => void;
disabled?: boolean;
placeholder?: string;
/**
* History recall. Fired when ↑/↓ hits a buffer boundary (empty
* buffer, or cursor already at the first/last line of a multi-line
* buffer). Parent walks its prompt history and swaps the value via
* `onChange`. Absent → keys are consumed silently.
*/
/** Ctrl+P / Ctrl+N — parent walks its prompt history and swaps `value` via `onChange`. */
onHistoryPrev?: () => void;
onHistoryNext?: () => void;
/** ↑/↓ on an empty buffer — parent scrolls chat history. */
onChatScrollUp?: () => void;
onChatScrollDown?: () => void;
}
export function PromptInput({
@@ -44,10 +48,16 @@ export function PromptInput({
placeholder,
onHistoryPrev,
onHistoryNext,
onChatScrollUp,
onChatScrollDown,
}: PromptInputProps) {
// Cap at 24 — collapseLinesForDisplay hides content past ~20 logical lines.
// Quantize spec.max to 4-row buckets so per-keystroke line-count changes
// don't churn viewport-budget; without this every single character that
// adds/removes a newline re-dispatches the allocator and reflows layout.
const inputLineCount = value.length > 0 ? value.split("\n").length : 1;
useReserveRows("input", { min: 1, max: Math.min(inputLineCount + 3, 24) });
const reserveMax = Math.min(Math.ceil(inputLineCount / 4) * 4 + 3, 24);
useReserveRows("input", { min: 1, max: reserveMax });
const [cursor, setCursor] = useState(value.length);
@@ -71,15 +81,19 @@ export function PromptInput({
const registerPaste = (content: string) => {
const v = lastLocalValueRef.current;
const c = cursorRef.current;
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
nextPasteIdRef.current = id + 1;
pastesRef.current.set(id, makePasteEntry(id, content));
const sentinel = encodePasteSentinel(id);
const next = v.slice(0, c) + sentinel + v.slice(c);
const insertion = shouldInlinePaste(content)
? content
: (() => {
const id = nextPasteIdRef.current % PASTE_SENTINEL_RANGE;
nextPasteIdRef.current = id + 1;
pastesRef.current.set(id, makePasteEntry(id, content));
return encodePasteSentinel(id);
})();
const next = v.slice(0, c) + insertion + v.slice(c);
lastLocalValueRef.current = next;
cursorRef.current = c + 1;
cursorRef.current = c + insertion.length;
onChange(next);
setCursor(c + 1);
setCursor(c + insertion.length);
};
useKeystroke((ev) => {
@@ -132,6 +146,8 @@ export function PromptInput({
}
if (action.historyHandoff === "prev") onHistoryPrev?.();
if (action.historyHandoff === "next") onHistoryNext?.();
if (action.chatScrollHandoff === "up") onChatScrollUp?.();
if (action.chatScrollHandoff === "down") onChatScrollDown?.();
}, !disabled);
// ── Render ──────────────────────────────────────────────────────
@@ -153,8 +169,6 @@ export function PromptInput({
const accentColor = disabled ? FG.faint : TONE.brand;
const cursorVisible = true;
const { line: cursorLine, col: cursorCol } = lineAndColumn(value, cursor);
// PromptInput paints its own cursor (`▌` / inverse char) — hide the real terminal cursor so it doesn't double up at the bottom of the screen.
useCursor({ col: 0, rowFromBottom: 0, visible: false });
const renderItems = collapseLinesForDisplay(lines, cursorLine);
const showHugeBufferHints = lines.length > 20;
+74 -9
View File
@@ -1,9 +1,10 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import type { SessionInfo } from "../../memory/session.js";
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import { FG, TONE, USD_TO_CNY } from "./theme/tokens.js";
import { FG, TONE, formatCost } from "./theme/tokens.js";
export type SessionPickerOutcome =
| { kind: "open"; name: string }
@@ -16,6 +17,10 @@ export interface SessionPickerProps {
sessions: ReadonlyArray<SessionInfo>;
workspace: string;
onChoose: (outcome: SessionPickerOutcome) => void;
/** Live wallet currency from App.tsx; falls back to each session's stored `meta.balanceCurrency` per row. */
walletCurrency?: string;
/** When provided, broadcasts to the web dashboard so it can resolve via `/api/modal/resolve`. */
pickerPorts?: PickerBroadcastPorts;
}
const PAGE_MARGIN = 6;
@@ -24,6 +29,8 @@ export function SessionPicker({
sessions,
workspace,
onChoose,
walletCurrency,
pickerPorts,
}: SessionPickerProps): React.ReactElement {
const [focus, setFocus] = useState(0);
const [renaming, setRenaming] = useState<{ from: string; buf: string } | null>(null);
@@ -31,6 +38,53 @@ export function SessionPicker({
const rows = stdout?.rows ?? 40;
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
const snapshot = useMemo(
() => ({
pickerKind: "sessions",
title: `pick a session — ${workspace}`,
items: sessions.map((s) => {
const branch = s.meta.branch ?? "main";
const summary =
s.meta.summary ?? `${s.messageCount} message${s.messageCount === 1 ? "" : "s"}`;
const turns = s.meta.turnCount ?? Math.ceil(s.messageCount / 2);
const currency = walletCurrency ?? s.meta.balanceCurrency;
const costLabel =
s.meta.totalCostUsd !== undefined ? formatCost(s.meta.totalCostUsd, currency, 2) : "";
return {
id: s.name,
title: s.name,
subtitle: summary,
badge: branch,
meta: costLabel ? `${turns} turns · ${costLabel}` : `${turns} turns`,
};
}),
actions: ["pick", "delete", "rename", "new", "cancel"] as const,
hint: "↑↓ pick · ⏎ open · [n] new · [d] delete · [r] rename · esc quit",
}),
[sessions, workspace, walletCurrency],
);
usePickerBroadcast(
!!pickerPorts,
{
...snapshot,
actions: [...snapshot.actions],
},
(res) => {
if (res.action === "pick") return onChoose({ kind: "open", name: res.id });
if (res.action === "delete") return onChoose({ kind: "delete", name: res.id });
if (res.action === "rename")
return onChoose({ kind: "rename", name: res.id, newName: res.text });
if (res.action === "new") return onChoose({ kind: "new" });
if (res.action === "cancel") return onChoose({ kind: "quit" });
},
pickerPorts ?? {
broadcast: () => undefined,
resolverRef: { current: null },
snapshotRef: { current: null },
},
);
useKeystroke((ev) => {
if (ev.paste) {
if (renaming) setRenaming({ ...renaming, buf: renaming.buf + ev.input });
@@ -101,7 +155,14 @@ export function SessionPicker({
<Text color={FG.faint}>{" to start a new one"}</Text>
</Box>
) : (
shown.map((s, i) => <SessionRow key={s.name} info={s} focused={start + i === focus} />)
shown.map((s, i) => (
<SessionRow
key={s.name}
info={s}
focused={start + i === focus}
walletCurrency={walletCurrency}
/>
))
)}
{hiddenBelow > 0 ? (
<Box>
@@ -135,15 +196,19 @@ export function SessionPicker({
function SessionRow({
info,
focused,
}: { info: SessionInfo; focused: boolean }): React.ReactElement {
walletCurrency,
}: {
info: SessionInfo;
focused: boolean;
walletCurrency?: string;
}): React.ReactElement {
const branch = info.meta.branch ?? "main";
const summary =
info.meta.summary ?? `${info.messageCount} message${info.messageCount === 1 ? "" : "s"}`;
const turns = info.meta.turnCount ?? Math.ceil(info.messageCount / 2);
const costCny =
info.meta.totalCostUsd !== undefined
? `¥${(info.meta.totalCostUsd * USD_TO_CNY).toFixed(2)}`
: "";
const currency = walletCurrency ?? info.meta.balanceCurrency;
const costLabel =
info.meta.totalCostUsd !== undefined ? formatCost(info.meta.totalCostUsd, currency, 2) : "";
const time = relativeTime(info.mtime);
return (
<Box>
@@ -156,7 +221,7 @@ function SessionRow({
<Box flexGrow={1} />
<Text color={FG.faint}>{`${time.padStart(11)} `}</Text>
<Text color={FG.faint}>{`${turns} turns`}</Text>
{costCny ? <Text color={FG.faint}>{` · ${costCny}`}</Text> : null}
{costLabel ? <Text color={FG.faint}>{` · ${costLabel}`}</Text> : null}
</Box>
);
}
-1
View File
@@ -1,5 +1,4 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { DenyContextInput } from "./DenyContextInput.js";
import { SingleSelect } from "./Select.js";
+2
View File
@@ -86,6 +86,7 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
const key = `slash.${spec.cmd}.description`;
const translated = t(key);
const summary = translated === key ? spec.summary : translated;
const aliasHint = spec.aliases?.length ? ` · /${spec.aliases.join(" /")}` : "";
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
@@ -99,6 +100,7 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
<Text color={isSelected ? COLOR.user : COLOR.info} dimColor={!isSelected}>
{summary}
</Text>
{aliasHint ? <Text dimColor>{aliasHint}</Text> : null}
</Box>
);
}
+12
View File
@@ -9,6 +9,8 @@ import { FG, TONE } from "./theme/tokens.js";
export interface WelcomeBannerProps {
/** True when running `reasonix code`. Surfaces code-mode hints. */
inCodeMode?: boolean;
/** Pinned workspace root — only meaningful in code mode. Surfaced so first-time users see they can pass --dir at next launch. */
workspaceRoot?: string;
/** Live URL of the embedded dashboard, or null when it isn't running. */
dashboardUrl?: string | null;
/** Bumped on language change; forces re-render so t() picks up new locale. */
@@ -19,6 +21,7 @@ const HINTS = ["/help", "/init", "/memory", "/cost"] as const;
export function WelcomeBanner({
inCodeMode,
workspaceRoot,
dashboardUrl,
}: WelcomeBannerProps): React.ReactElement {
const tagline = inCodeMode ? t("ui.taglineCode") : t("ui.taglineChat");
@@ -66,6 +69,15 @@ export function WelcomeBanner({
))}
</Box>
{inCodeMode && workspaceRoot ? (
<Box marginTop={1} flexDirection="row" gap={1}>
<Text color={TONE.brand}>{"▸ workspace"}</Text>
<Text color={FG.faint}>{"·"}</Text>
<Text color={FG.body}>{workspaceRoot}</Text>
<Text color={FG.faint}>{" (relaunch with --dir <path> to switch)"}</Text>
</Box>
) : null}
{dashboardUrl ? (
<Box marginTop={1} flexDirection="row" gap={1}>
<Text color={TONE.brand} bold>
+176 -54
View File
@@ -1,20 +1,17 @@
/**
* First-run / re-configure wizard.
*
* Walks a new user through: API key → preset pick → MCP server pick →
* per-server args → save. Saved output lives in `~/.reasonix/config.json`
* so the next `reasonix chat` starts with everything already wired.
*
* The wizard is the antidote to "too many CLI flags" — a new user should
* never have to read `--help` to get MCP + a sensible model combo
* working. Everything a user could set via `--mcp`, `--harvest`,
* `--branch`, etc. can be picked here in a few arrow-key presses.
* Walks a new user through: language → API key → preset pick → MCP server
* pick → per-server args → save. Saved output lives in
* `~/.reasonix/config.json` so the next `reasonix chat` starts with
* everything already wired.
*/
import { mkdirSync, statSync } from "node:fs";
import { Box, Text, useApp, useInput } from "ink";
import TextInput from "ink-text-input";
// biome-ignore lint/style/useImportType: JSX (jsx: "react") needs React as a value at runtime
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import {
type PresetName,
type ReasonixConfig,
@@ -24,6 +21,16 @@ import {
redactKey,
writeConfig,
} from "../../config.js";
import {
detectSystemLanguage,
getLanguage,
getSupportedLanguages,
notifyLanguageChange,
onLanguageChange,
setLanguage,
t,
} from "../../i18n/index.js";
import type { LanguageCode } from "../../i18n/types.js";
import { type CatalogEntry, MCP_CATALOG } from "../../mcp/catalog.js";
import { MultiSelect, type SelectItem, SingleSelect } from "./Select.js";
import { PRESET_DESCRIPTIONS } from "./presets.js";
@@ -42,22 +49,31 @@ export interface WizardProps {
};
}
type Step = "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
type Step = "language" | "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
interface WizardData {
language: LanguageCode;
apiKey: string;
preset: PresetName;
selectedCatalog: string[]; // entries from MCP_CATALOG by `name`
/** Captured user inputs per catalog entry that has `userArgs` (e.g. fs dir). */
selectedCatalog: string[];
catalogArgs: Record<string, string>;
}
const CATALOG_BY_NAME = new Map(MCP_CATALOG.map((e) => [e.name, e]));
const LANGUAGE_LABELS: Record<LanguageCode, string> = {
EN: "English",
"zh-CN": "简体中文",
};
export function Wizard({ onComplete, onCancel, existingApiKey, initial }: WizardProps) {
const { exit } = useApp();
const [step, setStep] = useState<Step>(existingApiKey ? "preset" : "apiKey");
const [, setLanguageVersion] = useState(0);
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
const [step, setStep] = useState<Step>("language");
const [data, setData] = useState<WizardData>({
language: getLanguage(),
apiKey: existingApiKey ?? "",
preset: initial?.preset ?? "auto",
selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
@@ -65,12 +81,24 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
});
const [error, setError] = useState<string | null>(null);
// Global Esc → cancel. Disabled once we've started saving to avoid
// ejecting out of a half-written state.
useInput((_input, key) => {
if (key.escape && step !== "saved" && onCancel) onCancel();
});
if (step === "language") {
return (
<LanguageStep
initialValue={data.language}
onSubmit={(lang) => {
setLanguage(lang);
notifyLanguageChange();
setData((d) => ({ ...d, language: lang }));
setStep(existingApiKey ? "preset" : "apiKey");
}}
/>
);
}
if (step === "apiKey") {
return (
<ApiKeyStep
@@ -87,7 +115,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
if (step === "preset") {
return (
<StepFrame title="Pick a preset" step={1} total={3}>
<StepFrame title={t("wizard.presetTitle")} step={1} total={3}>
<SingleSelect<PresetName>
items={presetItems()}
initialValue={data.preset}
@@ -97,7 +125,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
}}
/>
<Box marginTop={1}>
<Text dimColor>[↑↓] navigate · [Enter] confirm · [Esc] cancel</Text>
<Text dimColor>{t("wizard.selectFooter")}</Text>
</Box>
</StepFrame>
);
@@ -105,17 +133,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
if (step === "mcp") {
return (
<StepFrame title="Which MCP servers should Reasonix wire up for you?" step={2} total={3}>
<StepFrame title={t("wizard.mcpTitle")} step={2} total={3}>
<MultiSelect
items={mcpItems()}
initialSelected={data.selectedCatalog}
onSubmit={(selected) => {
setData((d) => ({ ...d, selectedCatalog: selected }));
// Only advance to the args step if any selected entry needs args.
const needsArgs = selected.some((name) => CATALOG_BY_NAME.get(name)?.userArgs);
setStep(needsArgs ? "mcpArgs" : "review");
}}
footer="[↑↓] navigate · [Space] toggle · [Enter] confirm · [Esc] cancel · empty = skip"
footer={t("wizard.mcpFooterMulti")}
/>
</StepFrame>
);
@@ -151,13 +178,21 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
if (step === "review") {
const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
return (
<StepFrame title="Ready to save" step={3} total={3}>
<StepFrame title={t("wizard.reviewTitle")} step={3} total={3}>
<Box flexDirection="column">
<SummaryLine label="API key" value={redactKey(data.apiKey)} />
<SummaryLine label="Preset" value={data.preset} />
<SummaryLine
label="MCP"
value={specs.length === 0 ? "(none)" : `${specs.length} server(s)`}
label={t("wizard.reviewLabelLanguage")}
value={LANGUAGE_LABELS[data.language]}
/>
<SummaryLine label={t("wizard.reviewLabelApiKey")} value={redactKey(data.apiKey)} />
<SummaryLine label={t("wizard.reviewLabelPreset")} value={data.preset} />
<SummaryLine
label={t("wizard.reviewLabelMcp")}
value={
specs.length === 0
? t("wizard.reviewMcpNone")
: t("wizard.reviewMcpServers", { count: specs.length })
}
/>
{specs.map((spec, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: review-only render, order fixed
@@ -166,7 +201,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
</Box>
))}
<Box marginTop={1}>
<Text>Saves to {defaultConfigPath()}</Text>
<Text>{t("wizard.reviewSavesTo", { path: defaultConfigPath() })}</Text>
</Box>
{error ? (
<Box marginTop={1}>
@@ -174,7 +209,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
</Box>
) : null}
<Box marginTop={1}>
<Text dimColor>[Enter] save · [Esc] cancel</Text>
<Text dimColor>{t("wizard.reviewFooter")}</Text>
</Box>
</Box>
<ReviewConfirm
@@ -195,7 +230,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
setStep("saved");
onComplete(next);
} catch (e) {
setError(`Could not save config: ${(e as Error).message}`);
setError(t("wizard.reviewSaveError", { message: (e as Error).message }));
}
}}
/>
@@ -203,17 +238,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
);
}
// saved
return (
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
<Text bold color="green">
▸ Saved.
{t("wizard.savedTitle")}
</Text>
<Box marginTop={1}>
<Text>Run `reasonix` any time to start chatting — your settings are remembered.</Text>
<Text>{t("ui.welcome")}</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>[Enter] to exit</Text>
<Text dimColor>{t("wizard.savedFooter")}</Text>
</Box>
<ExitOnEnter onExit={exit} />
</Box>
@@ -222,6 +256,38 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
// ---------- step components ----------
function LanguageStep({
initialValue,
onSubmit,
}: {
initialValue: LanguageCode;
onSubmit: (lang: LanguageCode) => void;
}) {
const items: SelectItem<LanguageCode>[] = getSupportedLanguages().map((code) => ({
value: code,
label: LANGUAGE_LABELS[code],
hint: code === detectSystemLanguage() ? "(detected)" : undefined,
}));
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text bold color="cyan">
{t("wizard.languageTitle")}
</Text>
<Box marginTop={1}>
<Text dimColor>{t("wizard.languageSubtitle")}</Text>
</Box>
<Box marginTop={1}>
<SingleSelect<LanguageCode>
items={items}
initialValue={initialValue}
onSubmit={onSubmit}
footer={t("wizard.selectFooter")}
/>
</Box>
</Box>
);
}
function ApiKeyStep({
onSubmit,
error,
@@ -235,16 +301,16 @@ function ApiKeyStep({
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text bold color="cyan">
Welcome to Reasonix.
{t("wizard.welcomeTitle")}
</Text>
<Box marginTop={1}>
<Text>Paste your DeepSeek API key to get started.</Text>
<Text>{t("wizard.apiKeyPrompt")}</Text>
</Box>
<Text dimColor>Get one at: https://platform.deepseek.com/api_keys</Text>
<Text dimColor>Saved locally to {defaultConfigPath()}</Text>
<Text dimColor>{t("wizard.apiKeyGetOne")}</Text>
<Text dimColor>{t("wizard.apiKeySavedLocally", { path: defaultConfigPath() })}</Text>
<Box marginTop={1}>
<Text bold color="cyan">
{"key › "}
{t("wizard.apiKeyInputLabel")}
</Text>
<TextInput
value={value}
@@ -252,7 +318,7 @@ function ApiKeyStep({
onSubmit={(raw) => {
const trimmed = raw.trim();
if (!isPlausibleKey(trimmed)) {
onError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
onError(t("wizard.apiKeyInvalid"));
setValue("");
return;
}
@@ -268,7 +334,7 @@ function ApiKeyStep({
</Box>
) : value ? (
<Box marginTop={1}>
<Text dimColor>preview: {redactKey(value)}</Text>
<Text dimColor>{t("wizard.apiKeyPreview", { redacted: redactKey(value) })}</Text>
</Box>
) : null}
</Box>
@@ -287,8 +353,54 @@ function McpArgsStep({
onError: (e: string | null) => void;
}) {
const [value, setValue] = useState("");
const [pendingCreate, setPendingCreate] = useState<string | null>(null);
useInput((input, key) => {
if (!pendingCreate) return;
const ch = input.toLowerCase();
if (ch === "y" || key.return) {
try {
mkdirSync(pendingCreate, { recursive: true });
const created = pendingCreate;
setPendingCreate(null);
setValue("");
onError(null);
onSubmit(created);
} catch (e) {
onError(
t("wizard.mcpArgsDirCreateFailed", {
path: pendingCreate,
message: (e as Error).message,
}),
);
setPendingCreate(null);
}
} else if (ch === "n" || key.escape) {
setPendingCreate(null);
onError(null);
}
});
if (pendingCreate) {
return (
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
<Box flexDirection="column">
<Text>{t("wizard.mcpArgsDirMissing", { path: pendingCreate })}</Text>
<Box marginTop={1}>
<Text dimColor>{t("wizard.mcpArgsDirCreateHint")}</Text>
</Box>
{error ? (
<Box marginTop={1}>
<Text color="red">{error}</Text>
</Box>
) : null}
</Box>
</StepFrame>
);
}
return (
<StepFrame title={`Configure ${entry.name}`} step={2} total={3}>
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
<Box flexDirection="column">
<Text>{entry.summary}</Text>
{entry.note ? (
@@ -297,7 +409,7 @@ function McpArgsStep({
</Box>
) : null}
<Box marginTop={1}>
<Text>Required parameter: </Text>
<Text>{t("wizard.mcpArgsRequiredParam")}</Text>
<Text bold>{entry.userArgs}</Text>
</Box>
<Box marginTop={1}>
@@ -311,9 +423,20 @@ function McpArgsStep({
onSubmit={(raw) => {
const trimmed = raw.trim();
if (!trimmed) {
onError(`${entry.name} needs a value — got an empty string.`);
onError(t("wizard.mcpArgsEmpty", { name: entry.name }));
return;
}
if (entry.name === "filesystem") {
const check = checkFilesystemPath(trimmed);
if (check.kind === "missing") {
setPendingCreate(trimmed);
return;
}
if (check.kind === "not-a-dir") {
onError(t("wizard.mcpArgsNotADir", { path: trimmed }));
return;
}
}
onSubmit(trimmed);
setValue("");
}}
@@ -330,6 +453,14 @@ function McpArgsStep({
);
}
function checkFilesystemPath(p: string): { kind: "ok" | "missing" | "not-a-dir" } {
try {
return { kind: statSync(p).isDirectory() ? "ok" : "not-a-dir" };
} catch {
return { kind: "missing" };
}
}
function ReviewConfirm({ onConfirm }: { onConfirm: () => void }) {
useInput((_i, key) => {
if (key.return) onConfirm();
@@ -344,8 +475,6 @@ function ExitOnEnter({ onExit }: { onExit: () => void }) {
return null;
}
// ---------- small bits ----------
function StepFrame({
title,
step,
@@ -360,9 +489,7 @@ function StepFrame({
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Box>
<Text dimColor>
Step {step}/{total} ·{" "}
</Text>
<Text dimColor>{t("wizard.stepCounter", { step, total })}</Text>
<Text bold color="cyan">
{title}
</Text>
@@ -383,8 +510,6 @@ function SummaryLine({ label, value }: { label: string; value: string }) {
);
}
// ---------- data helpers ----------
function presetItems(): SelectItem<PresetName>[] {
return (["auto", "flash", "pro"] as const).map((name) => ({
value: name as PresetName,
@@ -396,7 +521,7 @@ function presetItems(): SelectItem<PresetName>[] {
function mcpItems(): SelectItem<string>[] {
return MCP_CATALOG.map((entry) => {
const hintParts: string[] = [entry.summary];
if (entry.userArgs) hintParts.push(`(you'll provide ${entry.userArgs})`);
if (entry.userArgs) hintParts.push(t("wizard.mcpUserArgsHint", { arg: entry.userArgs }));
if (entry.note) hintParts.push(entry.note);
return {
value: entry.name,
@@ -413,9 +538,6 @@ function placeholderFor(entry: CatalogEntry): string {
}
function deriveInitialCatalog(existingSpecs: string[]): string[] {
// Best-effort recovery: if the user previously picked catalog entries,
// the spec strings look like `name=npx -y <pkg> ...`. Match by package
// so reconfigure pre-checks the same boxes.
const packageToName = new Map(MCP_CATALOG.map((e) => [e.package, e.name]));
const out: string[] = [];
for (const spec of existingSpecs) {
@@ -436,7 +558,7 @@ function deriveInitialCatalog(existingSpecs: string[]): string[] {
*/
export function buildSpec(name: string, argsByName: Record<string, string>): string {
const entry = CATALOG_BY_NAME.get(name);
if (!entry) return name; // shouldn't happen; fall back gracefully
if (!entry) return name;
const userArg = entry.userArgs ? argsByName[name] : undefined;
const tail = userArg ? ` ${quoteIfNeeded(userArg)}` : "";
return `${entry.name}=npx -y ${entry.package}${tail}`;
-39
View File
@@ -1,39 +0,0 @@
/** Alt-screen lifecycle — restore must be idempotent and run on SIGINT/SIGTERM/exit so the user's main buffer always comes back. */
import { useEffect } from "react";
function isInteractiveTty(): boolean {
return Boolean(process.stdout?.isTTY);
}
export function useAltScreen(): void {
useEffect(() => {
if (!isInteractiveTty()) return;
// 1002 = button-event tracking (press + drag + release); 1006 = SGR coords.
// Shift+drag bypasses tracking for native terminal selection.
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H\x1b[?1002h\x1b[?1006h");
let restored = false;
const restore = () => {
if (restored) return;
restored = true;
try {
process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?1049l");
} catch {
/* terminal closed */
}
};
process.once("exit", restore);
process.once("SIGINT", () => {
restore();
process.exit(130);
});
process.once("SIGTERM", () => {
restore();
process.exit(143);
});
return restore;
}, []);
}
+7 -5
View File
@@ -1,5 +1,4 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import type { Card } from "../state/cards.js";
import { FG } from "../theme/tokens.js";
@@ -21,11 +20,14 @@ import { UsageCard } from "./UsageCard.js";
import { UserCard } from "./UserCard.js";
import { WarnCard } from "./WarnCard.js";
export function CardRenderer({ card }: { card: Card }): React.ReactElement {
// No marginTop here — Card primitive owns inter-card spacing. Doubling it
// bakes 2 blank rows into every static-emitted block of bytes.
// Memoized so the cards array re-rendering (every store update) only
// reconciles cards whose object identity actually changed — the reducer
// keeps prior cards reference-stable, so unchanged history skips work.
export const CardRenderer = React.memo(function CardRenderer({
card,
}: { card: Card }): React.ReactElement {
return <Box flexDirection="column">{renderCard(card)}</Box>;
}
});
function renderCard(card: Card): React.ReactElement {
switch (card.kind) {
+72 -5
View File
@@ -1,7 +1,9 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import React, { useContext } from "react";
import { clipToCells, wrapToCells } from "../../../frame/width.js";
import { countTokens } from "../../../tokenizer.js";
import { LiveExpandContext } from "../layout/LiveExpandContext.js";
import { useReserveRows } from "../layout/viewport-budget.js";
import { Markdown } from "../markdown.js";
import { Card } from "../primitives/Card.js";
@@ -10,17 +12,49 @@ import { PILL_MODEL, Pill, modelBadgeFor } from "../primitives/Pill.js";
import { Spinner } from "../primitives/Spinner.js";
import type { StreamingCard as StreamingCardData } from "../state/cards.js";
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
import { useSlowTick } from "../ticker.js";
/** Streaming preview tail length — bounded live region so chunks don't thrash whole-card layout. */
const STREAMING_PREVIEW_LINES = 4;
/** Expanded mode shows up to this many lines so the card can't swallow the whole viewport. */
const EXPANDED_MAX_LINES = 60;
const MIN_ELAPSED_MS_FOR_RATE = 500;
const MIN_TOKENS_FOR_RATE = 4;
function formatTokenCount(n: number): string {
if (n >= 10000) return `${(n / 1000).toFixed(1)}k`;
if (n >= 1000) return `${(n / 1000).toFixed(2)}k`;
return String(n);
}
function tokenRate(
text: string,
startTs: number,
endTs: number,
): { tokens: number; tps: number | null } {
const tokens = countTokens(text);
const elapsedMs = endTs - startTs;
if (elapsedMs < MIN_ELAPSED_MS_FOR_RATE || tokens < MIN_TOKENS_FOR_RATE) {
return { tokens, tps: null };
}
return { tokens, tps: Math.round((tokens * 1000) / elapsedMs) };
}
const PILL_RATE = { bg: "#11141a", fg: "#8b949e" } as const;
export function StreamingCard({ card }: { card: StreamingCardData }): React.ReactElement {
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
const expanded = useContext(LiveExpandContext);
const reserveCap = expanded ? EXPANDED_MAX_LINES + 2 : STREAMING_PREVIEW_LINES + 2;
useReserveRows("stream", {
min: STREAMING_PREVIEW_LINES + 1,
max: STREAMING_PREVIEW_LINES + 2,
max: reserveCap,
});
// Re-render at 1Hz so the rate keeps updating even when chunks stall.
// Frozen once `card.done` is true — settled cards render via Static.
useSlowTick();
const modelBadge = card.model ? modelBadgeFor(card.model) : null;
const modelPill = modelBadge ? (
@@ -28,9 +62,24 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
) : null;
if (card.done && !card.aborted) {
const { tokens, tps } = tokenRate(card.text, card.ts, card.endedAt ?? Date.now());
const ratePill =
tokens >= MIN_TOKENS_FOR_RATE && tps !== null ? (
<Pill label={`${formatTokenCount(tokens)} tok · ${tps} t/s`} {...PILL_RATE} bold={false} />
) : null;
return (
<Card tone={TONE.ok}>
<CardHeader glyph="‹" tone={TONE.ok} title="reply" right={modelPill} />
<CardHeader
glyph="‹"
tone={TONE.ok}
title="reply"
right={
<>
{ratePill}
{modelPill}
</>
}
/>
<Markdown text={card.text} />
</Card>
);
@@ -39,12 +88,23 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
const lineCells = Math.max(20, cols - 4);
const allLines = card.text.length > 0 ? card.text.split("\n") : [""];
const visualLines = allLines.flatMap((l) => wrapToCells(l, lineCells));
const visible = visualLines.slice(-STREAMING_PREVIEW_LINES);
const cap = expanded ? EXPANDED_MAX_LINES : STREAMING_PREVIEW_LINES;
const visible = visualLines.slice(-cap);
const droppedAbove = Math.max(0, visualLines.length - visible.length);
const aborted = !!card.aborted;
const headColor = aborted ? TONE.err : TONE_ACTIVE.brand;
const glyph = aborted ? "‹" : "◈";
const headLabel = aborted ? "aborted" : "writing…";
const { tokens: liveTokens, tps: liveTps } = tokenRate(card.text, card.ts, Date.now());
const liveRatePill =
!aborted && liveTokens >= MIN_TOKENS_FOR_RATE && liveTps !== null ? (
<Pill label={`${liveTps} t/s`} {...PILL_RATE} bold={false} />
) : null;
const expandPill = !aborted ? (
<Pill label={expanded ? "expanded ⌃o" : "preview ⌃o"} {...PILL_RATE} bold={false} />
) : null;
return (
<Card tone={headColor}>
<CardHeader
@@ -53,13 +113,20 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
title={headLabel}
right={
<>
{liveRatePill}
{expandPill}
{aborted ? null : <Spinner kind="braille" color={TONE_ACTIVE.brand} />}
{modelPill}
</>
}
/>
{expanded && droppedAbove > 0 ? (
<Text
color={FG.faint}
>{`⋯ ${droppedAbove} earlier line${droppedAbove === 1 ? "" : "s"} above`}</Text>
) : null}
{visible.map((line, i) => (
<Box key={`${card.id}:${allLines.length - visible.length + i}`} flexDirection="row">
<Box key={`${card.id}:${visualLines.length - visible.length + i}`} flexDirection="row">
<Text color={aborted ? FG.meta : FG.body}>{clipToCells(line, lineCells)}</Text>
</Box>
))}
@@ -0,0 +1,90 @@
import type { MutableRefObject } from "react";
import { useEffect } from "react";
import type {
DashboardEvent,
PickerAction,
PickerItem,
PickerResolution,
} from "../../../server/context.js";
export interface PickerSnapshot {
pickerKind: string;
title: string;
query?: string;
items: PickerItem[];
actions: PickerAction[];
hasMore?: boolean;
hint?: string;
}
export interface ViewerSnapshot {
viewerKind: string;
title: string;
body?: string;
steps?: Array<{ id: string; title: string; status: "done" | "queued" }>;
meta?: string;
}
export interface ViewerBroadcastPorts {
broadcast: (ev: DashboardEvent) => void;
resolverRef: MutableRefObject<(() => void) | null>;
snapshotRef: MutableRefObject<ViewerSnapshot | null>;
}
/** Read-only sibling of `usePickerBroadcast` — viewer modals carry no selection so only `close` flows back. */
export function useViewerBroadcast(
active: boolean,
snapshot: ViewerSnapshot,
onClose: () => void,
ports: ViewerBroadcastPorts,
): void {
const { broadcast, resolverRef, snapshotRef } = ports;
useEffect(() => {
if (!active) return;
return () => {
broadcast({ kind: "modal-down", modalKind: "viewer" });
if (resolverRef.current) resolverRef.current = null;
if (snapshotRef.current) snapshotRef.current = null;
};
}, [active, broadcast, resolverRef, snapshotRef]);
useEffect(() => {
if (!active) return;
snapshotRef.current = snapshot;
resolverRef.current = onClose;
broadcast({ kind: "modal-up", modal: { kind: "viewer", ...snapshot } });
}, [active, snapshot, onClose, broadcast, resolverRef, snapshotRef]);
}
export interface PickerBroadcastPorts {
broadcast: (ev: DashboardEvent) => void;
resolverRef: MutableRefObject<((res: PickerResolution) => void) | null>;
snapshotRef: MutableRefObject<PickerSnapshot | null>;
}
/** Mirrors a TUI picker into the dashboard via modal-up/down events. Caller passes stable refs from App.tsx so identity does not churn the effect. */
export function usePickerBroadcast(
active: boolean,
snapshot: PickerSnapshot,
onResolve: (res: PickerResolution) => void,
ports: PickerBroadcastPorts,
): void {
const { broadcast, resolverRef, snapshotRef } = ports;
useEffect(() => {
if (!active) return;
return () => {
broadcast({ kind: "modal-down", modalKind: "picker" });
if (resolverRef.current) resolverRef.current = null;
if (snapshotRef.current) snapshotRef.current = null;
};
}, [active, broadcast, resolverRef, snapshotRef]);
useEffect(() => {
if (!active) return;
snapshotRef.current = snapshot;
resolverRef.current = onResolve;
broadcast({ kind: "modal-up", modal: { kind: "picker", ...snapshot } });
}, [active, snapshot, onResolve, broadcast, resolverRef, snapshotRef]);
}
+39
View File
@@ -0,0 +1,39 @@
/** stdin-queue drain on exit — eats stuck terminal-feature-detection responses (#365). */
import process from "node:process";
/** Eats stuck OSC/CPR/DA replies on exit so fish/bash don't print them as input (#365). */
export async function drainTtyResponses(timeoutMs = 50): Promise<void> {
const stdin = process.stdin;
if (!stdin.isTTY && typeof (stdin as { setRawMode?: unknown }).setRawMode !== "function") {
return;
}
let raised = false;
try {
stdin.setRawMode(true);
raised = true;
} catch {
return;
}
stdin.resume();
await new Promise<void>((resolve) => {
const onData = (_chunk: Buffer | string): void => {
// Discard — anything pending here is a terminal-feature reply.
};
stdin.on("data", onData);
const timer = setTimeout(() => {
stdin.off("data", onData);
stdin.pause();
if (raised) {
try {
stdin.setRawMode(false);
} catch {
/* stdin may already be closed; ignore */
}
}
resolve();
}, timeoutMs);
timer.unref();
});
}
+1 -3
View File
@@ -32,9 +32,7 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
return { kind: "consumed" };
}
if (result.clear) {
// 2J + 3J + H: nuke viewport AND scrollback (visible buffer + the
// off-screen lines). Then reset state.cards so React doesn't
// immediately repaint everything that was just wiped.
// 2J + 3J + H: visible buffer + scrollback + cursor home.
ctx.stdoutWrite("\x1b[2J\x1b[3J\x1b[H");
ctx.log.reset();
if (result.info) ctx.log.pushInfo(result.info);
+51
View File
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from "react";
/** Rows advanced per PgUp / PgDn / arrow / wheel — small for smooth feel. */
export const SCROLL_PAGE_ROWS = 3;
export interface ChatScrollState {
/** How many rows of content are above the visible viewport. */
scrollRows: number;
/** True when the user is following the bottom (auto-advances on new content). */
pinned: boolean;
scrollUp: () => void;
scrollDown: () => void;
/** Jump straight to the latest content and resume auto-follow (End key). */
jumpToBottom: () => void;
/** CardStream calls this once it has measured inner/outer Box heights. */
setMaxScroll: (rows: number) => void;
}
/** Row-precision scroll state. CardStream reports maxScroll back via setMaxScroll once Ink has laid out the inner column. */
export function useChatScroll(): ChatScrollState {
const [scrollRows, setScrollRows] = useState(0);
const [pinned, setPinned] = useState(true);
const [maxScroll, setMaxScrollState] = useState(0);
useEffect(() => {
if (pinned) setScrollRows(maxScroll);
}, [pinned, maxScroll]);
useEffect(() => {
if (scrollRows > maxScroll) setScrollRows(maxScroll);
}, [scrollRows, maxScroll]);
const scrollUp = useCallback(() => {
setPinned(false);
setScrollRows((o) => Math.max(0, o - SCROLL_PAGE_ROWS));
}, []);
const scrollDown = useCallback(() => {
setScrollRows((o) => {
const next = Math.min(maxScroll, o + SCROLL_PAGE_ROWS);
if (next >= maxScroll) setPinned(true);
return next;
});
}, [maxScroll]);
const jumpToBottom = useCallback(() => setPinned(true), []);
const setMaxScroll = useCallback((rows: number) => setMaxScrollState(rows), []);
return { scrollRows, pinned, scrollUp, scrollDown, jumpToBottom, setMaxScroll };
}
+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) {
+6
View File
@@ -27,7 +27,13 @@ const CSI_TAIL_TO_FLAGS: ReadonlyArray<{ tail: string; flags: CsiKeyFlags }> = [
// Forward-delete (the key labelled Delete on most keyboards).
{ tail: "[3~", flags: { delete: true } },
// Shift+Tab — terminal sends `\x1b[Z` rather than tab-with-shift.
// `[1;2Z` is the modifier-encoded variant some Windows PowerShell
// hosts emit; `[27;2;9~` and `[9;2u` cover modifyOtherKeys / Kitty
// forms. Issue #373.
{ tail: "[Z", flags: { shift: true, tab: true } },
{ tail: "[1;2Z", flags: { shift: true, tab: true } },
{ tail: "[27;2;9~", flags: { shift: true, tab: true } },
{ tail: "[9;2u", flags: { shift: true, tab: true } },
];
function alreadyStructured(flags: CsiKeyFlags): boolean {
+59 -32
View File
@@ -1,13 +1,62 @@
import { Static } from "ink";
import { Box, type DOMElement, Text, useBoxMetrics } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import React, { useEffect, useRef } from "react";
import { CardRenderer } from "../cards/CardRenderer.js";
import { ActiveCardContext } from "../primitives/Card.js";
import type { Card } from "../state/cards.js";
import { useAgentState } from "../state/provider.js";
import { FG } from "../theme/tokens.js";
/** Settled = no future event can mutate it; safe to commit via Ink's Static. */
function isSettled(card: Card): boolean {
/**
* Row-precision virtual scroll: outer Box clips with overflow="hidden",
* inner Box holds all cards and slides up via negative marginTop. Yoga
* computes inner height from card layout; useBoxMetrics reports it back
* so App can clamp scroll bounds. New cards arriving while the user is
* scrolled up sit below the visible window — bytes outside outer bounds
* aren't written, so terminal text selection survives streaming activity.
*/
export function CardStream({
scrollRows,
onMaxScrollChange,
suppressLive = false,
}: {
scrollRows: number;
onMaxScrollChange: (rows: number) => void;
suppressLive?: boolean;
}): React.ReactElement {
const cards = useAgentState((s) => s.cards);
// useBoxMetrics types its ref as RefObject<DOMElement> (non-null); the
// null! assertion satisfies the signature — Ink populates the ref on
// mount before the metrics hook reads it.
const outerRef = useRef<DOMElement>(null!);
const innerRef = useRef<DOMElement>(null!);
const outer = useBoxMetrics(outerRef);
const inner = useBoxMetrics(innerRef);
const maxScroll = Math.max(0, inner.height - outer.height);
useEffect(() => {
onMaxScrollChange(maxScroll);
}, [maxScroll, onMaxScrollChange]);
let visible = cards;
if (suppressLive && cards.length > 0 && !isFullySettled(cards[cards.length - 1]!)) {
visible = cards.slice(0, -1);
}
return (
<>
{scrollRows > 0 ? <Text color={FG.faint}>{" ↑ earlier — PgUp / wheel / ↑"}</Text> : null}
<Box ref={outerRef} flexDirection="column" flexGrow={1} overflow="hidden">
<Box ref={innerRef} flexDirection="column" marginTop={-scrollRows} flexShrink={0}>
{visible.map((card) => (
<CardRenderer key={card.id} card={card} />
))}
</Box>
</Box>
</>
);
}
function isFullySettled(card: Card): boolean {
switch (card.kind) {
case "streaming":
case "tool":
@@ -15,34 +64,12 @@ function isSettled(card: Card): boolean {
return card.done || !!card.aborted;
case "reasoning":
return !card.streaming || !!card.aborted;
case "task":
case "subagent":
return card.status !== "running";
case "plan":
return card.steps.every((s) => s.status === "done" || s.status === "skipped");
default:
return true;
}
}
export function CardStream(): React.ReactElement {
const cards = useAgentState((s) => s.cards);
const lastIdx = cards.length - 1;
const lastCard = lastIdx >= 0 ? (cards[lastIdx] as Card) : null;
const lastIsLive = !!lastCard && !isSettled(lastCard);
const committed: Card[] = lastIsLive ? cards.slice(0, lastIdx) : cards.slice();
const live: Card[] = lastIsLive && lastCard ? [lastCard] : [];
// Static items are emitted via bridge.emitStatic, which renders them in an
// off-tree React reconciler — context from the live tree does NOT propagate.
// The ActiveCardContext.Provider must therefore live inside the children
// function so it travels with the rendered subtree.
return (
<>
<Static items={committed}>
{(card) => (
<ActiveCardContext.Provider value={false} key={card.id}>
<CardRenderer card={card} />
</ActiveCardContext.Provider>
)}
</Static>
{live.map((card) => (
<CardRenderer key={card.id} card={card} />
))}
</>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from "react";
/** ctrl-o toggles this; live streaming card swaps 4-line tail for full-tail view. */
export const LiveExpandContext = createContext<boolean>(false);
+16 -19
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({
@@ -113,17 +103,20 @@ function ModePill({
export function UndoBanner({
banner,
}: {
banner: { results: ApplyResult[]; expiresAt: number };
banner: { results: ApplyResult[]; expiresAt: number; pausedRemainingMs: number | null };
}) {
useTick();
const totalMs = 5000;
const remainingMs = Math.max(0, banner.expiresAt - Date.now());
const paused = banner.pausedRemainingMs !== null;
const remainingMs = paused
? (banner.pausedRemainingMs ?? 0)
: Math.max(0, banner.expiresAt - Date.now());
const remainingSec = Math.ceil(remainingMs / 1000);
const ok = banner.results.filter((r) => r.status === "applied" || r.status === "created").length;
const total = banner.results.length;
const urgent = remainingSec <= 1;
const urgent = !paused && remainingSec <= 1;
const pct = (remainingMs / totalMs) * 100;
const tone = urgent ? TONE.err : TONE.accent;
const tone = paused ? TONE.warn : urgent ? TONE.err : TONE.accent;
return (
<Box marginY={1} paddingX={1}>
<Text backgroundColor={TONE.accent} color="black" bold>
@@ -133,11 +126,15 @@ export function UndoBanner({
<Text backgroundColor={TONE.brand} color="black" bold>
{" u "}
</Text>
<Text color={FG.faint}>{" to undo "}</Text>
<Text color={FG.faint}>{paused ? " to undo · " : " to undo · "}</Text>
<Text backgroundColor={paused ? TONE.warn : FG.faint} color="black" bold>
{" space "}
</Text>
<Text color={FG.faint}>{paused ? " to resume " : " to pause "}</Text>
<CharBar pct={pct} width={20} color={tone} showLabel={false} />
<Text color={FG.faint}>{" "}</Text>
<Text color={tone} bold={urgent}>
{`${remainingSec}s`}
<Text color={tone} bold={urgent || paused}>
{paused ? `${remainingSec}s · paused` : `${remainingSec}s`}
</Text>
</Box>
);
+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>
+27 -31
View File
@@ -1,6 +1,6 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { inkCompat } from "../../renderer/index.js";
import { type InlineSpan, type MdLine, markdownToLines } from "./markdown-lines.js";
const FG_BODY = "#c9d1d9";
@@ -13,9 +13,6 @@ const TONE_WARN = "#f0b07d";
const SURFACE_ELEV = "#161b22";
export function MarkdownView({ text }: { text: string }): React.ReactElement {
// No useMemo: the renderer's Static-path uses a one-shot tree walker that
// doesn't set up a React dispatcher, so hook calls would throw. Parsing on
// every render is cheap (marked.lexer is allocation-light).
return <MarkdownLines lines={markdownToLines(text)} />;
}
@@ -25,35 +22,34 @@ export function MarkdownLines({
lines: ReadonlyArray<MdLine>;
}): React.ReactElement {
return (
<inkCompat.Box flexDirection="column">
<Box flexDirection="column">
{lines.map((line, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + stable per render
<LineRow key={`md-${i}-${line.kind}`} line={line} />
))}
</inkCompat.Box>
</Box>
);
}
function LineRow({ line }: { line: MdLine }): React.ReactElement | null {
switch (line.kind) {
case "blank":
return <inkCompat.Text> </inkCompat.Text>;
return <Text> </Text>;
case "hr":
return <inkCompat.Text color={FG_FAINT}>──────</inkCompat.Text>;
return <Text color={FG_FAINT}>──────</Text>;
case "heading":
return (
<inkCompat.Box>
<inkCompat.Text bold color={FG_STRONG}>
<Box>
<Text bold color={FG_STRONG}>
{`${"#".repeat(line.level)} `}
</inkCompat.Text>
</Text>
<Spans spans={line.spans} bold strongColor />
</inkCompat.Box>
</Box>
);
case "paragraph":
return (
<inkCompat.Box>
<Box>
<Spans spans={line.spans} />
</inkCompat.Box>
</Box>
);
case "list": {
const indent = " ".repeat(line.depth * 2);
@@ -68,20 +64,20 @@ function LineRow({ line }: { line: MdLine }): React.ReactElement | null {
const markerColor =
line.task === "done" ? TONE_OK : line.task === "todo" ? FG_FAINT : FG_META;
return (
<inkCompat.Box>
<inkCompat.Text color={markerColor}>{`${indent}${marker} `}</inkCompat.Text>
<Box>
<Text color={markerColor}>{`${indent}${marker} `}</Text>
<Spans spans={line.spans} dim={line.task === "done"} strike={line.task === "done"} />
</inkCompat.Box>
</Box>
);
}
case "code":
return <CodeBlock lang={line.lang} text={line.text} />;
case "blockquote":
return (
<inkCompat.Box>
<inkCompat.Text color={TONE_BRAND}>{"▎ "}</inkCompat.Text>
<Box>
<Text color={TONE_BRAND}>{"▎ "}</Text>
<Spans spans={line.spans} italic />
</inkCompat.Box>
</Box>
);
}
}
@@ -93,15 +89,15 @@ function spanKey(span: InlineSpan, i: number): string {
function CodeBlock({ lang, text }: { lang: string; text: string }): React.ReactElement {
const lines = text.split("\n");
return (
<inkCompat.Box flexDirection="column">
{lang.length > 0 ? <inkCompat.Text color={FG_META}>{` ${lang}`}</inkCompat.Text> : null}
<Box flexDirection="column">
{lang.length > 0 ? <Text color={FG_META}>{` ${lang}`}</Text> : null}
{lines.map((ln, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: code lines are positional + stable per render
<inkCompat.Text key={`code-${i}`} backgroundColor={SURFACE_ELEV}>
<Text key={`code-${i}`} backgroundColor={SURFACE_ELEV}>
{` ${ln} `}
</inkCompat.Text>
</Text>
))}
</inkCompat.Box>
</Box>
);
}
@@ -115,7 +111,7 @@ interface SpansProps {
}
function Spans({ spans, bold, italic, dim, strike, strongColor }: SpansProps): React.ReactElement {
if (spans.length === 0) return <inkCompat.Text> </inkCompat.Text>;
if (spans.length === 0) return <Text> </Text>;
return (
<>
{spans.map((span, i) => (
@@ -150,9 +146,9 @@ function SpanText({
}): React.ReactElement {
if (span.code) {
return (
<inkCompat.Text color={FG_STRONG} backgroundColor={SURFACE_ELEV}>
<Text color={FG_STRONG} backgroundColor={SURFACE_ELEV}>
{` ${span.text} `}
</inkCompat.Text>
</Text>
);
}
const color = span.fileRef
@@ -163,7 +159,7 @@ function SpanText({
? FG_STRONG
: FG_BODY;
return (
<inkCompat.Text
<Text
color={color}
bold={!!(span.bold || ambientBold)}
italic={!!(span.italic || ambientItalic)}
@@ -172,6 +168,6 @@ function SpanText({
underline={!!(span.link || span.fileRef)}
>
{span.text}
</inkCompat.Text>
</Text>
);
}
+33 -30
View File
@@ -127,7 +127,6 @@ function ListItem({
// biome-ignore lint/suspicious/noArrayIndexKey: list-item children are positional and stable per render
return <List key={`l-${i}`} token={tok as Tokens.List} depth={depth + 1} />;
}
// biome-ignore lint/suspicious/noArrayIndexKey: list-item children are positional and stable per render
return <BlockToken key={`b-${i}-${tok.type}`} token={tok} />;
})}
</Box>
@@ -328,31 +327,29 @@ function FallbackTable({
}): React.ReactElement {
return (
<Box flexDirection="column">
{headerCells.map((h, ci) => {
const label = `${padToCells(h, labelPad - 2)}: `;
const values = bodyCells.map((r) => r[ci] ?? "");
return (
// biome-ignore lint/suspicious/noArrayIndexKey: header cells positional
<Box key={`fk-${ci}`} flexDirection="column">
{values.map((v, ri) => {
const lines = wrapToCells(v, valueCells);
return lines.map((line, li) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fallback table lines are positional
<Box key={`fv-${ri}-${li}`}>
{li === 0 ? (
<Text bold color={FG.sub}>
{label}
</Text>
) : (
<Text>{padToCells("", labelPad)}</Text>
)}
<Text color={FG.body}>{line}</Text>
</Box>
));
})}
</Box>
);
})}
{bodyCells.map((row, ri) => (
// biome-ignore lint/suspicious/noArrayIndexKey: body rows positional
<Box key={`fr-${ri}`} flexDirection="column">
{ri > 0 ? <Text> </Text> : null}
{headerCells.map((h, ci) => {
const label = `${padToCells(h, labelPad - 2)}: `;
const lines = wrapToCells(row[ci] ?? "", valueCells);
return lines.map((line, li) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fallback table lines are positional
<Box key={`fc-${ri}-${ci}-${li}`}>
{li === 0 ? (
<Text bold color={FG.sub}>
{label}
</Text>
) : (
<Text>{padToCells("", labelPad)}</Text>
)}
<Text color={FG.body}>{line}</Text>
</Box>
));
})}
</Box>
))}
</Box>
);
}
@@ -370,12 +367,17 @@ function Inline({ tokens }: { tokens: Token[] }): React.ReactElement {
const FILE_REF_RE = /\b([A-Za-z0-9_./@\-]+\.[A-Za-z0-9]{1,6})(?::(\d+)(?:-(\d+))?)?\b/g;
const MENTION_RE = /(?<![A-Za-z0-9_])@([A-Za-z0-9_./\-]+\.[A-Za-z0-9]{1,6})/g;
function osc8(label: string, target: string, color: string): React.ReactElement {
const open = `\x1b]8;;${target}\x07`;
const close = "\x1b]8;;\x07";
function looksLikeFileRef(path: string, hasLine: boolean): boolean {
if (hasLine) return true;
if (path.includes("/")) return true;
const ext = path.split(".").pop() ?? "";
return ext.length >= 2;
}
function osc8(label: string, _target: string, color: string): React.ReactElement {
return (
<Text color={color} underline>
{`${open}${label}${close}`}
{label}
</Text>
);
}
@@ -402,6 +404,7 @@ function renderInlineText(raw: string): React.ReactElement {
if (hits.some((h) => start < h.end && end > h.start)) continue;
const path = m[1]!;
const line = m[2];
if (!looksLikeFileRef(path, line !== undefined)) continue;
const target = line ? `file://${path}:${line}` : `file://${path}`;
hits.push({ start, end, node: osc8(m[0], target, TONE.brand) });
}
+10 -9
View File
@@ -28,8 +28,10 @@ export interface MultilineAction {
/** When `true`, fire `onSubmit(submitValue ?? value)`. */
submit: boolean;
submitValue?: string;
/** Set when ↑/↓ hits a buffer boundary — parent handles history recall. */
/** Set on Ctrl+P / Ctrl+N — parent handles prompt-history recall. */
historyHandoff?: "prev" | "next";
/** Set when ↑/↓ fires on an empty buffer — parent scrolls the chat history. */
chatScrollHandoff?: "up" | "down";
/** Reducer is pure — hands raw paste to PromptInput which allocates a sentinel and inserts that. */
pasteRequest?: { content: string };
}
@@ -68,12 +70,11 @@ export function processMultilineKey(
return cursor === value.length ? NOOP : { next: null, cursor: value.length, submit: false };
}
// History recall is bound to Ctrl+P / Ctrl+N (bash readline
// convention) rather than ↑/↓. Reason: Windows Terminal + ConPTY
// translates mouse wheel events to ↑/↓ keystrokes for raw-mode
// child processes — indistinguishable at the byte level. If ↑/↓
// recalled history, every wheel-scroll would corrupt the prompt
// with a prior message. Ctrl+P/N is wheel-immune.
// Prompt-history recall is on Ctrl+P / Ctrl+N (bash readline
// convention). ↑/↓ on an empty buffer scrolls chat history because
// Windows Terminal + ConPTY translates mouse wheel events to ↑/↓ in
// raw mode — the only way to make wheel-up scroll chat is to map ↑
// to chat-scroll. Ctrl+P/N is wheel-immune.
if (key.ctrl && key.input === "p") {
return { ...NOOP, historyHandoff: "prev" };
}
@@ -90,12 +91,12 @@ export function processMultilineKey(
return { next: null, cursor: Math.min(value.length, cursor + 1), submit: false };
}
if (key.upArrow) {
if (value.length === 0) return { ...NOOP, historyHandoff: "prev" };
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "up" };
const moved = moveCursorUp(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
if (key.downArrow) {
if (value.length === 0) return { ...NOOP, historyHandoff: "next" };
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "down" };
const moved = moveCursorDown(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
+2 -7
View File
@@ -1,8 +1,7 @@
import { Box } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useContext } from "react";
/** Settled cards (in scrollback) drop the border + paddingX so history reads as flat lines. */
/** Settled cards (in scrollback) drop border + padding + margin so history collapses to flat lines. */
export const ActiveCardContext = React.createContext(true);
export interface CardProps {
@@ -24,11 +23,7 @@ const STRIPE_BORDER = {
export function Card({ tone, children }: CardProps): React.ReactElement {
const active = useContext(ActiveCardContext);
if (!active) {
return (
<Box flexDirection="column" marginTop={1}>
{children}
</Box>
);
return <Box flexDirection="column">{children}</Box>;
}
return (
<Box
+7 -4
View File
@@ -1,7 +1,7 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import React, { useContext } from "react";
import { FG } from "../theme/tokens.js";
import { ActiveCardContext } from "./Card.js";
export type MetaItem = string | { text: string; color: string };
@@ -31,6 +31,9 @@ export function CardHeader({
meta,
right,
}: CardHeaderProps): React.ReactElement {
const active = useContext(ActiveCardContext);
// Settled scrollback drops faint string meta + spinners; colored badges (rejected, retry) stay.
const visibleMeta = active ? meta : meta?.filter((item) => typeof item !== "string");
return (
<Box flexDirection="row" gap={1}>
<Text color={tone}>{glyph}</Text>
@@ -44,7 +47,7 @@ export function CardHeader({
</Text>
)}
{subtitle ? <Text color={FG.body}>{subtitle}</Text> : null}
{meta?.map((item, i) => {
{visibleMeta?.map((item, i) => {
const isStr = typeof item === "string";
const text = isStr ? item : item.text;
const color = isStr ? FG.faint : item.color;
@@ -56,7 +59,7 @@ export function CardHeader({
</React.Fragment>
);
})}
{right}
{active ? right : null}
</Box>
);
}
+4 -11
View File
@@ -1,16 +1,13 @@
import { Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { useTick } from "../ticker.js";
const FRAMES = {
circle: ["◐", "◓", "◑", "◒"] as const,
braille: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"] as const,
};
const CADENCE_MS = {
circle: 200,
braille: 80,
} as const;
export interface SpinnerProps {
kind?: keyof typeof FRAMES;
color?: string;
@@ -19,12 +16,8 @@ export interface SpinnerProps {
export function Spinner({ kind = "circle", color, bold }: SpinnerProps): React.ReactElement {
const frames = FRAMES[kind];
const [frame, setFrame] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setFrame((f) => (f + 1) % frames.length), CADENCE_MS[kind]);
return () => clearInterval(id);
}, [kind, frames.length]);
const tick = useTick();
const frame = tick % frames.length;
return (
<Text bold={bold} color={color}>
+34 -7
View File
@@ -1,7 +1,7 @@
import type { SlashArgContext, SlashCommandSpec } from "./types.js";
export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
{ cmd: "help", summary: "show the full command reference" },
{ cmd: "help", summary: "show the full command reference", aliases: ["?"] },
{ cmd: "status", summary: "current model, flags, context, session" },
{
cmd: "preset",
@@ -52,6 +52,7 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argsHint: "<EN|zh-CN>",
summary: "switch the runtime language",
argCompleter: ["EN", "zh-CN"],
aliases: ["lang"],
},
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
{
@@ -76,10 +77,17 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argsHint: "[list|show <name>|forget <name>|clear <scope> confirm]",
summary: "show / manage pinned memory (REASONIX.md + ~/.reasonix/memory)",
},
{
cmd: "search-engine",
argsHint: "<mojeek|searxng> [<endpoint>]",
summary: "switch web search backend — mojeek (default, no deps) or searxng (self-hosted)",
argCompleter: ["mojeek", "searxng"],
aliases: ["se"],
},
{
cmd: "skill",
argsHint: "[list|show <name>|<name> [args]]",
summary: "list / run user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
argsHint: "[list|show <name>|new <name>|<name> [args]]",
summary: "list / run / scaffold user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
},
{
cmd: "hooks",
@@ -147,13 +155,17 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
summary: "show semantic_search status — built? Ollama installed? how to enable",
},
{ cmd: "clear", summary: "clear visible scrollback only (log/context kept)" },
{ cmd: "new", summary: "start a fresh conversation (clear context + scrollback)" },
{
cmd: "new",
summary: "start a fresh conversation (clear context + scrollback)",
aliases: ["reset"],
},
{
cmd: "loop",
argsHint: "<5s..6h> <prompt> · stop · (no args = status)",
summary: "auto-resubmit <prompt> every <interval> until you type something / Esc / /loop stop",
},
{ cmd: "exit", summary: "quit the TUI" },
{ cmd: "exit", summary: "quit the TUI", aliases: ["quit", "q"] },
// Code-mode only
{
cmd: "init",
@@ -253,16 +265,31 @@ export function suggestSlashCommands(prefix: string, codeMode = false): SlashCom
const p = prefix.toLowerCase();
return SLASH_COMMANDS.filter((c) => {
if (c.contextual === "code" && !codeMode) return false;
return c.cmd.startsWith(p);
if (c.cmd.startsWith(p)) return true;
return c.aliases?.some((a) => a.startsWith(p)) ?? false;
});
}
/** alias → canonical cmd map, derived from SLASH_COMMANDS at module init. */
const ALIAS_TO_CMD: Readonly<Record<string, string>> = (() => {
const m: Record<string, string> = {};
for (const spec of SLASH_COMMANDS) {
if (!spec.aliases) continue;
for (const a of spec.aliases) m[a] = spec.cmd;
}
return m;
})();
export function resolveSlashAlias(name: string): string {
return ALIAS_TO_CMD[name] ?? name;
}
/** Picker fires only when arg tail has no internal whitespace; past that it's a usage hint. */
export function detectSlashArgContext(input: string, codeMode = false): SlashArgContext | null {
// `/cmd <rest>` — one space, rest captured up to end-of-buffer.
const m = /^\/(\S+) ([\s\S]*)$/.exec(input);
if (!m) return null;
const cmdName = m[1]!.toLowerCase();
const cmdName = resolveSlashAlias(m[1]!.toLowerCase());
const tail = m[2] ?? "";
const spec = SLASH_COMMANDS.find(
(s) => s.cmd === cmdName && (s.contextual !== "code" || codeMode),
+4 -1
View File
@@ -1,4 +1,5 @@
import type { CacheFirstLoop } from "../../../loop.js";
import { resolveSlashAlias } from "./commands.js";
import { handlers as adminHandlers } from "./handlers/admin.js";
import { handlers as basicHandlers } from "./handlers/basic.js";
import { handlers as dashboardHandlers } from "./handlers/dashboard.js";
@@ -15,6 +16,7 @@ import { handlers as plansHandlers } from "./handlers/plans.js";
import { handlers as semanticHandlers } from "./handlers/semantic.js";
import { handlers as sessionsHandlers } from "./handlers/sessions.js";
import { handlers as skillHandlers } from "./handlers/skill.js";
import { handlers as webSearchEngineHandlers } from "./handlers/web-search-engine.js";
import type { SlashContext, SlashResult } from "./types.js";
/** Synchronous return — async work fires-and-forgets via `ctx.postInfo` to keep input non-blocking. */
@@ -37,6 +39,7 @@ const HANDLERS: Record<string, SlashHandler> = {
...semanticHandlers,
...sessionsHandlers,
...skillHandlers,
...webSearchEngineHandlers,
};
export function handleSlash(
@@ -45,7 +48,7 @@ export function handleSlash(
loop: CacheFirstLoop,
ctx: SlashContext = {},
): SlashResult {
const h = HANDLERS[cmd];
const h = HANDLERS[resolveSlashAlias(cmd)];
if (h) return h(args, loop, ctx);
return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
}
-3
View File
@@ -189,13 +189,10 @@ const loop: SlashHandler = (args, _loop, ctx) => {
export const handlers: Record<string, SlashHandler> = {
exit,
quit: exit,
clear,
new: resetLog,
reset: resetLog,
keys,
help,
"?": help,
retry,
loop,
};
+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) {
-1
View File
@@ -25,5 +25,4 @@ export const handlers: Record<string, SlashHandler> = {
return { info: t("slash.language.success") };
},
lang: (args, loop, ctx) => handlers.language!(args, loop, ctx),
};
+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
+10 -23
View File
@@ -7,10 +7,10 @@ const model: SlashHandler = (args, loop, ctx) => {
const id = args[0];
const known = ctx.models ?? null;
if (!id) {
const hint = known && known.length > 0 ? known.join(" | ") : t("handlers.model.modelHint");
return { info: t("handlers.model.modelUsage", { hint }) };
return { openModelPicker: true };
}
loop.configure({ model: id });
ctx.dispatch?.({ type: "session.model.change", model: id });
if (known && known.length > 0 && !known.includes(id)) {
return {
info: t("handlers.model.modelNotInCatalog", { id, list: known.join(", ") }),
@@ -53,7 +53,7 @@ const harvest: SlashHandler = (args, loop) => {
return { info: t("handlers.model.harvestOff") };
};
const preset: SlashHandler = (args, loop) => {
const preset: SlashHandler = (args, loop, ctx) => {
const name = (args[0] ?? "").toLowerCase();
const applyAndPersist = (effort: "high" | "max") => {
try {
@@ -62,8 +62,7 @@ const preset: SlashHandler = (args, loop) => {
/* disk full / perms — runtime change still took effect */
}
};
if (name === "auto") {
const p = PRESETS.auto;
const apply = (p: (typeof PRESETS)[keyof typeof PRESETS]) => {
loop.configure({
model: p.model,
autoEscalate: p.autoEscalate,
@@ -71,31 +70,19 @@ const preset: SlashHandler = (args, loop) => {
harvest: p.harvest,
branch: p.branch,
});
ctx.dispatch?.({ type: "session.model.change", model: p.model });
applyAndPersist(p.reasoningEffort);
};
if (name === "auto") {
apply(PRESETS.auto);
return { info: t("handlers.model.presetAuto") };
}
if (name === "flash") {
const p = PRESETS.flash;
loop.configure({
model: p.model,
autoEscalate: p.autoEscalate,
reasoningEffort: p.reasoningEffort,
harvest: p.harvest,
branch: p.branch,
});
applyAndPersist(p.reasoningEffort);
apply(PRESETS.flash);
return { info: t("handlers.model.presetFlash") };
}
if (name === "pro") {
const p = PRESETS.pro;
loop.configure({
model: p.model,
autoEscalate: p.autoEscalate,
reasoningEffort: p.reasoningEffort,
harvest: p.harvest,
branch: p.branch,
});
applyAndPersist(p.reasoningEffort);
apply(PRESETS.pro);
return { info: t("handlers.model.presetPro") };
}
return { info: t("handlers.model.presetUsage") };
@@ -132,6 +132,9 @@ const status: SlashHandler = (_args, loop, ctx) => {
: "";
const dashUrl = ctx.getDashboardUrl?.();
const dashLine = dashUrl ? t("handlers.observability.statusDash", { url: dashUrl }) : "";
const workspaceLine = ctx.codeRoot
? t("handlers.observability.statusWorkspace", { path: ctx.codeRoot })
: "";
const lines = [
t("handlers.observability.statusModel", { model: loop.model }),
t("handlers.observability.statusFlags", {
@@ -145,6 +148,7 @@ const status: SlashHandler = (_args, loop, ctx) => {
mcpLine,
sessionLine,
];
if (workspaceLine) lines.push(workspaceLine);
if (budgetLine) lines.push(budgetLine);
if (pendingLine) lines.push(pendingLine);
if (planLine) lines.push(planLine);
+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> {
+18 -1
View File
@@ -6,6 +6,17 @@ const skill: SlashHandler = (args, _loop, ctx) => {
const store = new SkillStore({ projectRoot: ctx.codeRoot });
const sub = (args[0] ?? "").toLowerCase();
if (sub === "new" || sub === "init") {
const name = args[1];
if (!name) return { info: t("handlers.skill.newUsage") };
const wantsGlobal = args.slice(2).includes("--global") || !ctx.codeRoot;
const result = store.create(name, wantsGlobal ? "global" : "project");
if ("error" in result) {
return { info: t("handlers.skill.newError", { reason: result.error }) };
}
return { info: t("handlers.skill.newCreated", { name, path: result.path }) };
}
if (sub === "" || sub === "list" || sub === "ls") {
const skills = store.list();
if (skills.length === 0) {
@@ -17,7 +28,13 @@ const skill: SlashHandler = (args, _loop, ctx) => {
if (!store.hasProjectScope()) {
lines.push(t("handlers.skill.listProjectOnly"));
}
lines.push("", t("handlers.skill.listFrontmatter"), t("handlers.skill.listInvoke"));
lines.push(
"",
t("handlers.skill.listFrontmatter"),
t("handlers.skill.listInvoke"),
"",
t("handlers.skill.listEmptyNewHint"),
);
return { info: lines.join("\n") };
}
const lines = [t("handlers.skill.listHeader", { count: skills.length })];
@@ -0,0 +1,48 @@
import { readConfig, webSearchEndpoint, webSearchEngine, writeConfig } from "../../../../config.js";
import { t } from "../../../../i18n/index.js";
import type { SlashHandler } from "../dispatch.js";
export const handlers: Record<string, SlashHandler> = {
"search-engine": (args, _loop, ctx) => {
const engine = args[0];
if (!engine || (engine !== "mojeek" && engine !== "searxng")) {
return {
info: [
t("handlers.webSearchEngine.currentEngine", { engine: webSearchEngine() }),
t("handlers.webSearchEngine.endpoint", { url: webSearchEndpoint() }),
"",
t("handlers.webSearchEngine.usageHeader"),
t("handlers.webSearchEngine.usageMojeek"),
t("handlers.webSearchEngine.usageSearxng"),
t("handlers.webSearchEngine.usageSearxngUrl"),
"",
t("handlers.webSearchEngine.alias"),
"",
t("handlers.webSearchEngine.searxngInfo"),
t("handlers.webSearchEngine.searxngInstall"),
].join("\n"),
};
}
const cfg = readConfig();
cfg.webSearchEngine = engine;
if (engine === "searxng" && args[1]) {
const raw = args[1];
cfg.webSearchEndpoint = raw.includes("://") ? raw : `http://${raw}`;
}
writeConfig(cfg);
const note =
engine === "searxng"
? t("handlers.webSearchEngine.switchedSearxngNote", { endpoint: webSearchEndpoint() })
: "";
ctx.postInfo?.(t("handlers.webSearchEngine.switched", { engine, note }));
const detail =
engine === "searxng"
? t("handlers.webSearchEngine.confirmedDetail", { endpoint: webSearchEndpoint() })
: "";
return { info: t("handlers.webSearchEngine.confirmed", { engine, detail }) };
},
se: (args, loop, ctx) => handlers["search-engine"]!(args, loop, ctx),
};
+6
View File
@@ -10,6 +10,10 @@ export interface SlashResult {
info?: string;
/** Open the SessionPicker modal mid-chat — used by `/sessions` slash. */
openSessionsPicker?: boolean;
/** Open the CheckpointPicker modal — bare `/restore` (no name argument). */
openCheckpointPicker?: boolean;
/** Open the ModelPicker modal — bare `/model` (no id) opens it. */
openModelPicker?: boolean;
/** Open the unified MCP hub — `/mcp` defaults to "live", `/mcp browse` to "marketplace". */
openMcpHub?: { tab: "live" | "marketplace" };
/** Open the arg-completer picker for this command (e.g. `/language` → language picker). */
@@ -132,6 +136,8 @@ export interface SlashCommandSpec {
argsHint?: string;
/** First-arg picker source — file paths intentionally absent (use `@path` mentions instead). */
argCompleter?: "models" | "mcp-resources" | "mcp-prompts" | readonly string[];
/** Alternate names — typing any of these resolves to `cmd` for dispatch / suggestion / arg-context. */
aliases?: readonly string[];
}
export interface SlashArgContext {
+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);
}
}
+8
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({
@@ -122,6 +124,11 @@ const sessionUpdate = z.object({
}),
});
const sessionModelChange = z.object({
type: z.literal("session.model.change"),
model: z.string().min(1),
});
const focusMove = z.object({
type: z.literal("focus.move"),
direction: z.enum(["next", "prev", "first", "last"]),
@@ -318,6 +325,7 @@ export const AgentEventSchema = z.discriminatedUnion("type", [
networkChange,
languageChange,
sessionUpdate,
sessionModelChange,
focusMove,
focusSet,
cardToggle,
+7 -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 }));
@@ -116,6 +116,11 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
case "session.update":
return { ...state, status: { ...state.status, ...event.patch } };
case "session.model.change":
return state.session.model === event.model
? state
: { ...state, session: { ...state.session, model: event.model } };
case "focus.move":
return {
...state,
+9 -3
View File
@@ -62,17 +62,23 @@ const CSI_TAIL_MAP: ReadonlyArray<{ tail: string; ev: KeyEvent }> = [
{ tail: "6~", ev: { input: "", pageDown: true } },
{ tail: "3~", ev: { input: "", delete: true } },
{ tail: "Z", ev: { input: "", shift: true, tab: true } },
// modifyOtherKeys (xterm CSI > 4 ; 2 m) sequences for Enter with
// modifiers. Only fired when App.tsx has enabled the mode at
// Some Windows hosts (PowerShell 7.x conhost path) emit the
// modifier-encoded back-tab `\x1b[1;2Z` instead of bare `\x1b[Z`.
// Issue #373 — without this entry Shift+Tab is silently dropped.
{ tail: "1;2Z", ev: { input: "", shift: true, tab: true } },
// modifyOtherKeys (xterm CSI > 4 ; 2 m) sequences for Enter / Tab
// with modifiers. Only fired when App.tsx has enabled the mode at
// startup; otherwise Shift+Enter stays indistinguishable from Enter.
// Modifier encoding: 2=shift, 3=alt, 4=alt+shift, 5=ctrl,
// 6=ctrl+shift, 7=ctrl+alt, 8=ctrl+alt+shift. Keycode 13 = Enter.
// 6=ctrl+shift, 7=ctrl+alt, 8=ctrl+alt+shift. Keycodes: 9=Tab, 13=Enter.
{ tail: "27;2;9~", ev: { input: "", tab: true, shift: true } },
{ tail: "27;2;13~", ev: { input: "", return: true, shift: true } },
{ tail: "27;5;13~", ev: { input: "", return: true, ctrl: true } },
{ tail: "27;6;13~", ev: { input: "", return: true, ctrl: true, shift: true } },
// Kitty keyboard protocol — same idea, different envelope:
// `\x1b[<keycode>;<mod>u`. Some terminals (kitty, recent Windows
// Terminal previews) prefer this shape. Harmless to map here too.
{ tail: "9;2u", ev: { input: "", tab: true, shift: true } },
{ tail: "13;2u", ev: { input: "", return: true, shift: true } },
{ tail: "13;5u", ev: { input: "", return: true, ctrl: true } },
{ tail: "13;6u", ev: { input: "", return: true, ctrl: true, shift: true } },
+32 -52
View File
@@ -1,89 +1,69 @@
import React, { type ReactNode, createContext, useContext, useEffect, useState } from "react";
import { useAnimation } from "ink";
import React, { type ReactNode, createContext, useContext, useState } from "react";
/**
* Two-tier global heartbeat. Both timers are owned by one provider so
* we still pay for a single setInterval per cadence, but components
* subscribe to whichever tier matches what they actually need to
* repaint:
* Two-tier global heartbeat backed by Ink 7's `useAnimation`. The
* provider only stores an `isActive` boolean; the actual frame timer
* lives inside Ink and consolidates with every other useAnimation
* caller into a single shared interval.
*
* - FAST_TICK_MS (120ms) — spinners, glyph pulses, anything that
* visibly animates frame-by-frame.
* - SLOW_TICK_MS (1000ms) — elapsed-seconds counters, expiry
* countdowns, out-of-React mutation pollers (job registries, file
* stat refresh). These don't NEED 8Hz re-renders — they update
* once per visible-state change anyway, so subscribing them to
* the fast tick was 7 wasted re-renders per second per consumer.
* countdowns, polling pollers. Don't need 8Hz re-renders.
*
* Splitting the contexts means a slow consumer (Elapsed timer) no
* longer reconciles when only the fast tick increments — React's
* context-propagation only fires for subscribers of the changed
* context. The streaming row's spinner and elapsed counter used to
* re-render together every 120ms; now the elapsed re-renders at 1Hz
* while the spinner keeps its 8Hz cadence.
* Setting `disabled` flips `isActive` to `false`, which Ink propagates
* to every active animation. Repaints stop entirely until isActive
* flips back, at which point Ink resets the frame counter to 0 (so
* spinners restart from frame 0 — visually identical to a fresh mount).
*/
export const FAST_TICK_MS = 120;
export const SLOW_TICK_MS = 1000;
/** @deprecated kept for callers that import the old name. */
export const TICK_MS = FAST_TICK_MS;
const FastTickContext = createContext(0);
const SlowTickContext = createContext(0);
const TickerActiveContext = createContext(true);
export interface TickerProviderProps {
children: ReactNode;
/**
* When true, the provider skips both setIntervals — ticks stay at 0,
* all consumers render once and never re-render from the timer. Used
* by PLAIN_UI mode so the cursor and any surviving spinners don't drive
* repaints on fragile Windows terminals.
* When true, every tick-driven animation pauses. Used by PLAIN_UI
* mode, modal overlays, and the idle-gate so a quiescent TUI is
* byte-stable (cursor blink and gradient pulses don't re-render).
*/
disabled?: boolean;
}
export function TickerProvider({ children, disabled }: TickerProviderProps) {
const [fast, setFast] = useState(0);
const [slow, setSlow] = useState(0);
useEffect(() => {
if (disabled) return;
const fastId = setInterval(() => setFast((t) => t + 1), FAST_TICK_MS);
const slowId = setInterval(() => setSlow((t) => t + 1), SLOW_TICK_MS);
return () => {
clearInterval(fastId);
clearInterval(slowId);
};
}, [disabled]);
return (
<FastTickContext.Provider value={fast}>
<SlowTickContext.Provider value={slow}>{children}</SlowTickContext.Provider>
</FastTickContext.Provider>
);
return <TickerActiveContext.Provider value={!disabled}>{children}</TickerActiveContext.Provider>;
}
function useTickerActive(): boolean {
return useContext(TickerActiveContext);
}
/**
* Fast tick — re-renders the calling component every {@link FAST_TICK_MS}.
* Use for spinner frames, glyph pulses, and anything else that
* visibly animates. If your component just shows a counter that
* updates once per second, prefer {@link useSlowTick}.
* Fast tick — re-renders the calling component every FAST_TICK_MS
* (120ms). Use for spinner frames, glyph pulses, anything that
* visibly animates frame-by-frame.
*/
export function useTick(): number {
return useContext(FastTickContext);
const isActive = useTickerActive();
return useAnimation({ interval: FAST_TICK_MS, isActive }).frame;
}
/**
* Slow tick — re-renders the calling component every {@link SLOW_TICK_MS}.
* Use for elapsed-seconds counters, expiry countdowns, or pollers
* that just need a "what's the time NOW?" trigger once per second.
* Slow tick — re-renders the calling component every SLOW_TICK_MS
* (1000ms). Use for elapsed-seconds counters, expiry countdowns,
* or pollers that just need a "what's the time NOW?" trigger once
* per second.
*/
export function useSlowTick(): number {
return useContext(SlowTickContext);
const isActive = useTickerActive();
return useAnimation({ interval: SLOW_TICK_MS, isActive }).frame;
}
/**
* Seconds elapsed since the calling component mounted. Subscribes to
* the slow tick — re-renders at 1Hz, which is exactly what a
* second-resolution counter needs. Earlier versions used the fast
* tick and re-rendered 8x/sec just to display the same number.
*/
/** Seconds elapsed since mount. Re-renders at 1Hz via the slow tick. */
export function useElapsedSeconds(): number {
const [start] = useState(() => Date.now());
useSlowTick();
+26 -1
View File
@@ -16,6 +16,8 @@ import {
export interface UndoBannerState {
results: ApplyResult[];
expiresAt: number;
/** Set when the user paused the countdown; banner stays up until they resume or hit `u`. */
pausedRemainingMs: number | null;
}
export interface UseEditHistoryResult {
@@ -30,6 +32,8 @@ export interface UseEditHistoryResult {
) => void;
/** Replaces the dismiss timer so multiple edits in one turn don't prematurely expire the window. */
armUndoBanner: (results: ApplyResult[]) => void;
/** Pause / resume the active undo countdown. No-ops if the banner is already settled. */
toggleUndoPause: () => void;
codeUndo: (args?: readonly string[]) => string;
codeHistory: () => string;
codeShowEdit: (args?: readonly string[]) => string;
@@ -77,7 +81,7 @@ export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEd
);
const armUndoBanner = useCallback<UseEditHistoryResult["armUndoBanner"]>((results) => {
setUndoBanner({ results, expiresAt: Date.now() + 5000 });
setUndoBanner({ results, expiresAt: Date.now() + 5000, pausedRemainingMs: null });
if (undoTimeoutRef.current) clearTimeout(undoTimeoutRef.current);
undoTimeoutRef.current = setTimeout(() => {
setUndoBanner(null);
@@ -85,6 +89,26 @@ export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEd
}, 5000);
}, []);
const toggleUndoPause = useCallback<UseEditHistoryResult["toggleUndoPause"]>(() => {
setUndoBanner((prev) => {
if (!prev) return prev;
if (prev.pausedRemainingMs === null) {
const remaining = Math.max(0, prev.expiresAt - Date.now());
if (undoTimeoutRef.current) {
clearTimeout(undoTimeoutRef.current);
undoTimeoutRef.current = null;
}
return { ...prev, pausedRemainingMs: remaining };
}
const remaining = prev.pausedRemainingMs;
undoTimeoutRef.current = setTimeout(() => {
setUndoBanner(null);
undoTimeoutRef.current = null;
}, remaining);
return { ...prev, expiresAt: Date.now() + remaining, pausedRemainingMs: null };
});
}, []);
const codeUndo = useCallback<UseEditHistoryResult["codeUndo"]>(
(args = []) => {
if (!codeMode) return "not in code mode";
@@ -266,6 +290,7 @@ export function useEditHistory(codeMode: { rootDir: string } | undefined): UseEd
undoBanner,
recordEdit,
armUndoBanner,
toggleUndoPause,
codeUndo,
codeHistory,
codeShowEdit,
+1
View File
@@ -158,6 +158,7 @@ You have TWO tools for running shell commands, and picking the right one is non-
After \`run_background\`, tools available to you:
- \`job_output(jobId, tailLines?)\` — read recent logs to verify startup / debug errors.
- \`wait_for_job(jobId, timeoutMs?)\` — block until the job exits or emits new output. Prefer this over repeating identical \`job_output\` calls while you're intentionally waiting.
- \`list_jobs\` — see every job this session (running + exited).
- \`stop_job(jobId)\` — SIGTERM → SIGKILL after grace. Stop before switching port / config.
+46 -10
View File
@@ -33,6 +33,10 @@ export interface ReasonixConfig {
session?: string | null;
setupCompleted?: boolean;
search?: boolean;
/** Web search engine backend: "mojeek" (default, scrapes Mojeek) or "searxng" (self-hosted SearXNG). */
webSearchEngine?: "mojeek" | "searxng";
/** Base URL for SearXNG instance (default http://localhost:8080). */
webSearchEndpoint?: string;
projects?: {
[absoluteRootDir: string]: {
shellAllowed?: string[];
@@ -93,18 +97,45 @@ export function searchEnabled(path: string = defaultConfigPath()): boolean {
return true;
}
export function webSearchEngine(path: string = defaultConfigPath()): "mojeek" | "searxng" {
const cfg = readConfig(path).webSearchEngine;
if (cfg === "searxng") return "searxng";
return "mojeek";
}
export function webSearchEndpoint(path: string = defaultConfigPath()): string {
const cfg = readConfig(path).webSearchEndpoint;
if (cfg && typeof cfg === "string") return cfg;
return "http://localhost:8080";
}
export function saveApiKey(key: string, path: string = defaultConfigPath()): void {
const cfg = readConfig(path);
cfg.apiKey = key.trim();
writeConfig(cfg, path);
}
/** Windows: case-insensitive — NTFS treats `F:\Foo` and `f:\foo` as one directory (#402). */
function findProjectKey(cfg: ReasonixConfig, rootDir: string): string | undefined {
const projects = cfg.projects;
if (!projects) return undefined;
if (Object.hasOwn(projects, rootDir)) return rootDir;
if (process.platform !== "win32") return undefined;
const lower = rootDir.toLowerCase();
for (const k of Object.keys(projects)) {
if (k.toLowerCase() === lower) return k;
}
return undefined;
}
export function loadProjectShellAllowed(
rootDir: string,
path: string = defaultConfigPath(),
): string[] {
const cfg = readConfig(path);
return cfg.projects?.[rootDir]?.shellAllowed ?? [];
const key = findProjectKey(cfg, rootDir);
if (key === undefined) return [];
return cfg.projects?.[key]?.shellAllowed ?? [];
}
export function addProjectShellAllowed(
@@ -116,10 +147,11 @@ export function addProjectShellAllowed(
if (!trimmed) return;
const cfg = readConfig(path);
if (!cfg.projects) cfg.projects = {};
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
const existing = cfg.projects[rootDir].shellAllowed ?? [];
const key = findProjectKey(cfg, rootDir) ?? rootDir;
if (!cfg.projects[key]) cfg.projects[key] = {};
const existing = cfg.projects[key].shellAllowed ?? [];
if (existing.includes(trimmed)) return;
cfg.projects[rootDir].shellAllowed = [...existing, trimmed];
cfg.projects[key].shellAllowed = [...existing, trimmed];
writeConfig(cfg, path);
}
@@ -132,12 +164,14 @@ export function removeProjectShellAllowed(
const trimmed = prefix.trim();
if (!trimmed) return false;
const cfg = readConfig(path);
const existing = cfg.projects?.[rootDir]?.shellAllowed ?? [];
const key = findProjectKey(cfg, rootDir);
if (key === undefined) return false;
const existing = cfg.projects?.[key]?.shellAllowed ?? [];
if (!existing.includes(trimmed)) return false;
const next = existing.filter((p) => p !== trimmed);
if (!cfg.projects) cfg.projects = {};
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
cfg.projects[rootDir].shellAllowed = next;
if (!cfg.projects[key]) cfg.projects[key] = {};
cfg.projects[key].shellAllowed = next;
writeConfig(cfg, path);
return true;
}
@@ -147,11 +181,13 @@ export function clearProjectShellAllowed(
path: string = defaultConfigPath(),
): number {
const cfg = readConfig(path);
const existing = cfg.projects?.[rootDir]?.shellAllowed ?? [];
const key = findProjectKey(cfg, rootDir);
if (key === undefined) return 0;
const existing = cfg.projects?.[key]?.shellAllowed ?? [];
if (existing.length === 0) return 0;
if (!cfg.projects) cfg.projects = {};
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
cfg.projects[rootDir].shellAllowed = [];
if (!cfg.projects[key]) cfg.projects[key] = {};
cfg.projects[key].shellAllowed = [];
writeConfig(cfg, path);
return existing.length;
}
+10 -3
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(
+192 -10
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: {
@@ -365,7 +509,7 @@ export const EN: TranslationSchema = {
" /clear clear displayed scrollback only (context kept — model still sees it)",
helpLoop:
" /loop <interval> <prompt> auto-resubmit <prompt> every <interval> (5s..6h). /loop stop · type anything to cancel.",
helpExit: " /exit quit",
helpExit: " /exit quit (aliases: /quit, /q)",
helpShellTitle: "Shell shortcut:",
helpShell: " !<cmd> run <cmd> in the sandbox root; output goes into",
helpShellDetail:
@@ -710,6 +854,8 @@ export const EN: TranslationSchema = {
statusBudget: " budget ${spent} / ${cap} ({pct}%){tag}",
statusSession: ' session "{name}" · {count} messages in log (resumed {resumed})',
statusSessionEphemeral: " session (ephemeral — no persistence)",
statusWorkspace:
" workspace {path} · pinned at launch (relaunch with --dir <path> to switch)",
statusMcp: " mcp {servers} server(s), {tools} tool(s) in registry",
statusEdits: " edits {count} pending (/apply to commit, /discard to drop)",
statusPlan: " plan ON — writes gated (submit_plan + approval)",
@@ -734,6 +880,10 @@ export const EN: TranslationSchema = {
"no archived plans yet for this session — `/replay` lights up once a plan completes (auto-archives when every step is done).",
replayInvalidIndex:
"invalid index — `/replay` takes 1..{max} (newest = 1). Use `/plans` to see the list.",
archivedRow: " ✓ {when} {total} step{s} · {completion} {label}",
completionComplete: "complete",
stopAborted:
"▸ plan stopped — model aborted; type a follow-up to continue or start a new task.",
},
jobs: {
codeOnly: "/jobs is only available inside `reasonix code`.",
@@ -805,6 +955,12 @@ export const EN: TranslationSchema = {
fallbackServers: "MCP servers ({count}):",
fallbackTools: "Tools in registry ({count}):",
fallbackChange: "To change this set, exit and run `reasonix setup`.",
usageDisableEnable:
"usage: /mcp {action} <name> · pick a name shown in /mcp (anonymous servers can't be named-toggled).",
usageReconnect: "usage: /mcp reconnect <name> · pick a name shown in /mcp.",
unknownServer: 'unknown MCP server "{name}". Known: {list}.',
noneList: "(none)",
reconnectNoTui: "/mcp reconnect requires the interactive TUI (postInfo not wired).",
},
init: {
codeOnly:
@@ -815,6 +971,27 @@ export const EN: TranslationSchema = {
existsPinned: " pinned into the system prompt every launch as-is.",
info: "▸ /init — model will scan the project and synthesize REASONIX.md.\n The result lands as a pending edit; review with /apply or /walk.",
},
semantic: {
codeOnly: "/semantic is only available inside `reasonix code` (needs a project root).",
checking: "▸ checking semantic_search status…",
},
webSearchEngine: {
currentEngine: "Current web search engine: {engine}",
endpoint: "SearXNG endpoint: {url}",
usageHeader: "Usage:",
usageMojeek: " /search-engine mojeek use Mojeek (default, no external deps)",
usageSearxng: " /search-engine searxng use SearXNG at default endpoint",
usageSearxngUrl: " /search-engine searxng <url> use SearXNG at custom endpoint",
alias: "Alias: /se",
searxngInfo:
"SearXNG is a self-hosted metasearch engine (https://github.com/searxng/searxng).",
searxngInstall: "Install it with: docker run -d -p 8080:8080 searxng/searxng",
switched: 'Switched web search engine to "{engine}".{note}',
switchedSearxngNote: " Make sure SearXNG is running at {endpoint}.",
confirmed:
'✓ Web search engine set to "{engine}"{detail}. Next assistant turn will pick up the change.',
confirmedDetail: " ({endpoint})",
},
skill: {
listEmpty: "no skills found. Reasonix reads skills from:",
listProjectScope:
@@ -825,11 +1002,16 @@ export const EN: TranslationSchema = {
listInvoke:
"Invoke a skill with `/skill <name> [args]` or by asking the model to call `run_skill`.",
listHeader: "User skills ({count}):",
listFooter: "View body: /skill show <name> Run: /skill <name> [args]",
listFooter: "View: /skill show <name> Run: /skill <name> [args] New: /skill new <name>",
listEmptyNewHint:
"Scaffold one with: /skill new <name> (project scope) — there's no remote registry yet; you author skills directly.",
showUsage: "usage: /skill show <name>",
showNotFound: "no skill found: {name}",
runNotFound: "no skill found: {name} (try /skill list)",
runInfo: "▸ running skill: {name}{args}",
newUsage: "usage: /skill new <name> [--global]",
newCreated: "▸ created skill: {name}\n {path}\n edit it, then `/skill {name}` to invoke",
newError: "▲ /skill new failed: {reason}",
},
},
};
+125 -7
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;
};
}
+182 -9
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: {
@@ -345,7 +484,7 @@ export const zhCN: TranslationSchema = {
helpClear: " /clear 仅清除显示的滚动回放(上下文保留 — 模型仍能看到)",
helpLoop:
" /loop <interval> <prompt> 每 <interval> 自动重新提交 <prompt>(5秒..6小时)。/loop stop · 输入任何内容取消。",
helpExit: " /exit 退出",
helpExit: " /exit 退出(别名:/quit, /q)",
helpShellTitle: "Shell 快捷方式:",
helpShell: " !<cmd> 在沙箱根目录运行 <cmd>;输出进入对话",
helpShellDetail: " 以便模型在下一轮看到。无允许列表限制。",
@@ -653,6 +792,7 @@ export const zhCN: TranslationSchema = {
statusBudget: " 预算 ${spent} / ${cap}({pct}%){tag}",
statusSession: ' 会话 "{name}" · 日志中 {count} 条消息(恢复了 {resumed} 条)',
statusSessionEphemeral: " 会话 (临时 — 无持久化)",
statusWorkspace: " 工作区 {path} · 启动时锁定(用 --dir <path> 重新启动以切换)",
statusMcp: " MCP {servers} 个服务器,注册表中 {tools} 个工具",
statusEdits: " 编辑 {count} 个待处理(/apply 提交,/discard 丢弃)",
statusPlan: " 计划 开启 — 写入受限(submit_plan + 审批)",
@@ -674,6 +814,9 @@ export const zhCN: TranslationSchema = {
"此会话尚无归档计划 — `/replay` 在计划完成后启用(每个步骤完成时自动归档)。",
replayInvalidIndex:
"无效索引 — `/replay` 接受 1..{max}(最新 = 1)。使用 `/plans` 查看列表。",
archivedRow: " ✓ {when} {total}步 · {completion} {label}",
completionComplete: "已完成",
stopAborted: "▸ 计划已停止 — 模型已中止;输入后续内容继续,或开始新任务。",
},
jobs: {
codeOnly: "/jobs 仅在 `reasonix code` 中可用。",
@@ -742,6 +885,12 @@ export const zhCN: TranslationSchema = {
fallbackServers: "MCP 服务器({count}):",
fallbackTools: "注册表中的工具({count}):",
fallbackChange: "要更改此设置,请退出并运行 `reasonix setup`。",
usageDisableEnable:
"用法:/mcp {action} <name> · 从 /mcp 列表中挑一个名字(匿名服务器无法按名切换)。",
usageReconnect: "用法:/mcp reconnect <name> · 从 /mcp 列表中挑一个名字。",
unknownServer: '未知 MCP 服务器 "{name}"。已知:{list}。',
noneList: "(无)",
reconnectNoTui: "/mcp reconnect 需要交互式 TUI(postInfo 未连接)。",
},
init: {
codeOnly:
@@ -752,6 +901,25 @@ export const zhCN: TranslationSchema = {
existsPinned: " 固定到每次启动的系统提示词中。",
info: "▸ /init — 模型将扫描项目并合成 REASONIX.md。\n 结果将作为待处理的编辑;使用 /apply 或 /walk 审查。",
},
semantic: {
codeOnly: "/semantic 仅在 `reasonix code` 中可用(需要项目根目录)。",
checking: "▸ 正在检查 semantic_search 状态…",
},
webSearchEngine: {
currentEngine: "当前网页搜索引擎:{engine}",
endpoint: "SearXNG 端点:{url}",
usageHeader: "用法:",
usageMojeek: " /search-engine mojeek 使用 Mojeek(默认,无外部依赖)",
usageSearxng: " /search-engine searxng 使用 SearXNG 默认端点",
usageSearxngUrl: " /search-engine searxng <url> 使用 SearXNG 自定义端点",
alias: "别名:/se",
searxngInfo: "SearXNG 是一个自托管的元搜索引擎(https://github.com/searxng/searxng)。",
searxngInstall: "安装命令: docker run -d -p 8080:8080 searxng/searxng",
switched: '已切换网页搜索引擎为 "{engine}"。{note}',
switchedSearxngNote: " 请确保 SearXNG 在 {endpoint} 运行。",
confirmed: '✓ 网页搜索引擎已设为 "{engine}"{detail}。下一轮模型调用将生效。',
confirmedDetail: "({endpoint})",
},
skill: {
listEmpty: "未找到技能。Reasonix 从以下位置读取技能:",
listProjectScope:
@@ -761,11 +929,16 @@ export const zhCN: TranslationSchema = {
listFrontmatter: "每个文件的 frontmatter 至少需要 `name` 和 `description`。",
listInvoke: "使用 `/skill <name> [args]` 调用技能,或让模型调用 `run_skill`。",
listHeader: "用户技能({count}):",
listFooter: "查看正文:/skill show <name> 运行:/skill <name> [args]",
listFooter: "查看:/skill show <name> 运行:/skill <name> [args] 新建:/skill new <name>",
listEmptyNewHint:
"用 `/skill new <name>` 在项目范围下生成一个空白模板 — 暂无在线市场,技能需要自己写。",
showUsage: "用法:/skill show <name>",
showNotFound: "未找到技能:{name}",
runNotFound: "未找到技能:{name} (尝试 /skill list)",
runInfo: "▸ 正在运行技能:{name}{args}",
newUsage: "用法:/skill new <name> [--global]",
newCreated: "▸ 已创建技能:{name}\n {path}\n 编辑后用 `/skill {name}` 调用",
newError: "▲ /skill new 失败:{reason}",
},
},
};
+1
View File
@@ -117,6 +117,7 @@ export {
formatSearchResults,
htmlToText,
parseMojeekResults,
parseSearxngHtmlResults,
registerWebTools,
webFetch,
webSearch,
+92 -32
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,
@@ -48,7 +49,12 @@ import {
import { TurnFailureTracker } from "./loop/turn-failure-tracker.js";
import type { BranchSummary, LoopEvent } from "./loop/types.js";
import { AppendOnlyLog, type ImmutablePrefix, VolatileScratch } from "./memory/runtime.js";
import { appendSessionMessage, loadSessionMessages, rewriteSession } from "./memory/session.js";
import {
appendSessionMessage,
loadSessionMessages,
loadSessionMeta,
rewriteSession,
} from "./memory/session.js";
import { type RepairReport, ToolCallRepair } from "./repair/index.js";
import { SessionStats, type TurnStats } from "./telemetry/stats.js";
import { countTokens } from "./tokenizer.js";
@@ -221,9 +227,15 @@ export class CacheFirstLoop {
}
return def.readOnly !== true;
};
const isStormExempt = (call: ToolCall): boolean => {
const name = call.function?.name;
if (!name) return false;
return registry.get(name)?.stormExempt === true;
};
this.repair = new ToolCallRepair({
allowedToolNames: allowedNames,
isMutating,
isStormExempt,
stormThreshold: parsePositiveIntEnv(process.env.REASONIX_STORM_THRESHOLD),
stormWindow: parsePositiveIntEnv(process.env.REASONIX_STORM_WINDOW),
});
@@ -240,6 +252,18 @@ export class CacheFirstLoop {
const tokensSaved = shrunk.tokensSaved;
for (const msg of messages) this.log.append(msg);
this.resumedMessageCount = messages.length;
// Carry forward cumulative cost / turn count so the TUI's session
// total continues across resumes; otherwise each restart resets to $0.
if (messages.length > 0) {
const meta = loadSessionMeta(this.sessionName);
this.stats.seedCarryover({
totalCostUsd: meta.totalCostUsd,
turnCount: meta.turnCount,
cacheHitTokens: meta.cacheHitTokens,
cacheMissTokens: meta.cacheMissTokens,
lastPromptTokens: meta.lastPromptTokens,
});
}
if (healedCount > 0) {
// Persist healed log so the same break isn't re-noticed every restart.
try {
@@ -374,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;
}
@@ -485,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;
}
@@ -494,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),
}),
};
}
}
@@ -539,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;
@@ -568,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.]";
@@ -614,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) {
@@ -622,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);
@@ -640,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);
@@ -657,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),
}),
};
}
}
@@ -925,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;
}
@@ -954,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
@@ -999,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
@@ -1041,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,
}),
};
}
@@ -1074,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;
}
@@ -1083,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",
@@ -1108,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") {
@@ -1130,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" });
@@ -1216,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: "" };
}
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff. Show more